Compare commits

..

160 Commits

Author SHA1 Message Date
Ace dba4c84cd5 Added finishing touch on other languages
Build & Auto-Repair / build (push) Has been cancelled
2026-07-08 14:24:03 +02:00
Ace d77ff2b965 Added logic to the 4 languages so it can search each language at a time
Build & Auto-Repair / build (push) Has been cancelled
2026-07-07 10:08:21 +02:00
Ace 0bc3ca58ed Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-07 10:03:38 +02:00
Ace d793604e92 Added 4 Languages English, Afrikaans, Xhosa, Zulu, tested but brought leads down to 3 leads only will see how to make it more without losing effiency of the model. 2026-07-07 10:03:35 +02:00
TroodonEnjoyer bc83af8e00 Started PostgreSQL — data directory was uninitialized, ran initdb, set password, switched to md5 auth, all 24 migrations applied.
Build & Auto-Repair / build (push) Has been cancelled
Hardened db.ts — added statement_timeout: 30000, idle_in_transaction_session_timeout: 10000, created transaction() helper (BEGIN/COMMIT/ROLLBACK).
Multi-step API routes wrapped in transactions — Events POST, Conversations POST use atomic blocks.
Fixed leads pagination — status filter pushed into SQL WHERE (no client-side filtering after LIMIT/OFFSET), added SELECT COUNT(*) for total.
Rewrote dashboard — SQL aggregations (COUNT + GROUP BY + date_trunc) replace loading all leads into memory.
Optimized conversations GET — merged duplicate correlated subqueries into single LEFT JOIN LATERAL.
Created migration 021_performance_indexes.sql — 8 missing indexes + set_session_user_context() function caching current_user_hierarchy_level() as session variable (avoids per-query RLS join).
Fixed build error — duplicate const other in conversations route. Also fixed a TypeScript never error in dashboard breakdown map.
Verified — npx tsc --noEmit clean, npm test (13 pass, 7 integration skip gracefully), npx next build succeeds.
2026-07-06 13:36:48 +02:00
Hannah_Bagga 80dee367e8 fixed theme setting
Build & Auto-Repair / build (push) Has been cancelled
2026-07-05 21:54:39 +02:00
Hannah_Bagga da99b695be just some things i needed to get back into the CRM
Build & Auto-Repair / build (push) Has been cancelled
2026-07-03 16:05:03 +02:00
caitlin dd6767980a search button theme fix in ai assistant
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 16:04:53 +02:00
Ace c1c4afadbb Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 13:26:58 +02:00
Ace 37679a7a60 Added Ai load fix 2026-07-02 13:26:56 +02:00
Hannah_Bagga c0cb715ced ai assistant page fixed
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 21:07:42 +02:00
Hannah_Bagga 1269f6cce8 Revert "Removed quick tips, recent prompts Messages today, Tokens used"
Build & Auto-Repair / build (push) Has been cancelled
This reverts commit 370f935cbb.
2026-07-01 14:36:47 +02:00
Hannah_Bagga 370f935cbb Removed quick tips, recent prompts Messages today, Tokens used
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 14:24:32 +02:00
Ace faf9dd551a Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 13:16:59 +02:00
Ace f67d9377a0 trying to fix AI chat 2026-07-01 13:16:57 +02:00
Hannah_Bagga 38fb3a47a8 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 13:14:35 +02:00
Hannah_Bagga bc422edcf7 colour scheme problem fixed 2026-07-01 13:13:31 +02:00
TroodonEnjoyer da5f8360bc Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 13:07:32 +02:00
TroodonEnjoyer 37af1febcc Automate DB migrations + fix audit trigger + drop forced password change
- scripts/run-migrations.mjs: auto-applies unapplied .sql files on
  npm run dev/setup via psql, tracks state in _migrations table
- 020_fixes.sql: sets password_change_required=FALSE for all users,
  fixes audit_password_change UUID cast, re-enables trigger
- package.json: hook migrations into dev:precheck
- setup.mjs: hook into npm run setup
- run_all.sql: add 020_fixes.sql to migration order
2026-07-01 13:07:15 +02:00
caitlin 84632e043f Merge origin/main into main
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:40:53 +02:00
Ace b2bb6d94f5 added the search button for the AI
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:29:06 +02:00
caitlin 566fa3df88 Fixed select a job category for lightmode 2026-07-01 12:21:16 +02:00
Ace 16710e3019 Getting the 10 syncs and adding my AI that works but has issues
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:16:22 +02:00
Ace e920d4925a Updated the to use targeted jobs but its weird 2026-07-01 12:15:37 +02:00
caitlin e8d80c3a16 login logo fix that i broke
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:04:05 +02:00
caitlin 77cccba8a1 Fixed Dark Mode
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 11:43:21 +02:00
Hannah_Bagga 7a5e833722 GIFs work now
Build & Auto-Repair / build (push) Has been cancelled
2026-06-30 15:11:15 +02:00
Hannah_Bagga 1e8f979cf9 AI Assistant page looks better.
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 22:00:05 +02:00
Hannah_Bagga 98145f0264 light and dark mode fixed and default mode is the default unless you pick Spidey mode
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 19:28:35 +02:00
caitlin 7a722a7d7e Merge remote-tracking branch 'origin/main' into master
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 15:38:18 +02:00
caitlin 61e9fac352 Fixed Light Mode 2026-06-29 15:26:44 +02:00
Ace 878627a6ba Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 15:08:09 +02:00
Ace 2c3a8e5333 Added qwen to serve as an repair agent. 2026-06-29 15:06:03 +02:00
Hannah_Bagga d870fb3ac7 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-29 14:52:13 +02:00
Hannah_Bagga f6d1d91fc6 AI Chat page looks better. Caitlin is my witness 2026-06-29 14:51:59 +02:00
TroodonEnjoyer bc3e345a27 Fix login session persistence + add admin accounts
- Fix JWT secret regenerating on every HMR cycle (globalThis cache)
- Replace unreliable response.cookies.set() with manual Set-Cookie header
- Remove jwt-secret.ts abstraction — JWT_SECRET back inline in auth.ts
- Sidebar logo links directly to /dashboard instead of server-redirect /
- Add 3 admin users to seed: ewan/caitlin/dillen (ADMIN role)
- Fix bcrypt hash corruption from PowerShell $ interpolation
- Remove plaintext passwords from codebase
- Fix audit_password_change trigger UUID type error
2026-06-29 14:42:16 +02:00
caitlin 3c0e7d76ca Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-29 10:42:58 +02:00
TroodonEnjoyer 39fb39db12 Fixed the login again? 2026-06-26 22:30:54 +02:00
Ace cd37d5a987 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 21:36:17 +02:00
Ace d138c60203 Fixing calander 2026-06-26 21:36:09 +02:00
caitlin 4d7a59c278 Changes in lightmode and dark mode header 2026-06-26 21:31:20 +02:00
TroodonEnjoyer aac9817ee7 Fix double login: set cookie on response object, invalidate sessions on dev restart
- Move cookie setting from cookies().set() to response.cookies.set() in
  login/logout routes (Next.js 15 ambient cookie merge is unreliable)
- Generate random JWT_SECRET at module scope in auth.ts for dev mode so
  every server start invalidates all prior sessions
- Replace blank page on auth failure with redirect to /login in dashboard layout
- Preserve ?redirect= param from middleware in login form
- Wrap login page in Suspense boundary for useSearchParams()

Fixes: double-login bug, session persistence across restarts, blank page on
session expiry
2026-06-26 21:29:36 +02:00
Hannah_Bagga fa97abb5b6 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 20:59:21 +02:00
Hannah_Bagga 3998285fd5 Fixed the gif picker just need to add in gifs 2026-06-26 20:58:57 +02:00
Hannah_Bagga 4c1db42873 Chat page fits whole screen now 2026-06-26 20:46:20 +02:00
Hannah_Bagga c428435f2f Removed video call, fixed logo, CRM changes with color theme 2026-06-26 20:30:46 +02:00
caitlin 886a7efe91 Ficed the leads per month in dev account 2026-06-26 19:52:02 +02:00
TroodonEnjoyer 1b5f244f28 Login possible fix and something else I can't remember 2026-06-26 19:39:45 +02:00
Hannah_Bagga 46a3216386 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:34:37 +02:00
Ace 8aba5c9ef4 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:34:05 +02:00
Ace 06181625c3 AI back please test out and tell me added, filter words for AI! 2026-06-26 19:31:07 +02:00
caitlin 08eba574f8 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:27:22 +02:00
Hannah_Bagga a876f2d769 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:23:02 +02:00
caitlin fbb37d6777 Fixed sidebar colur and Cpu and Ram 2026-06-26 19:22:57 +02:00
Hannah_Bagga 214c4a1852 Spiderman Theme Fixed 2026-06-26 19:22:54 +02:00
Ace c669ec0d04 added browser use for AI 2026-06-26 19:18:01 +02:00
Ace bf6fbe3ee4 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 18:47:54 +02:00
Ace d6534eb338 Commit Test 2026-06-26 18:47:39 +02:00
Ace a08f23ab0f Fixed the messages, chats, voice note, voice call, stickers, gifs, emoji's , file attachments 2026-06-26 18:47:35 +02:00
TroodonEnjoyer bccba1434b ATTEMPTED to fix my databse stuff, security etc, and a Issue Cait had 2026-06-26 18:37:28 +02:00
caitlin fe09d15359 Fixed the route issue 2026-06-26 17:59:40 +02:00
caitlin 2b8f2e7b59 Resolve merge conflicts in chats/page.tsx 2026-06-26 17:16:18 +02:00
caitlin fb99b49686 Fix for route 2026-06-26 16:53:37 +02:00
caitlin 75746f4cab Merge remote-tracking branch 'origin/main' into master 2026-06-26 16:47:37 +02:00
JCBSComputer a022e3cca3 Merge origin/main into local (conflict resolved in chats/page.tsx) 2026-06-26 16:43:44 +02:00
JCBSComputer 68483e9b60 Calender & Linked Added 2026-06-26 16:38:18 +02:00
Hannah_Bagga 21868fe336 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 16:37:44 +02:00
Rene 77b246b565 Attachment fixed 2026-06-26 16:35:31 +02:00
Hannah_Bagga 34e64fe6c7 Deleted 2026-06-26 16:34:20 +02:00
Ace ffa595451e Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 16:30:50 +02:00
Ace bd581739f9 Fuckups fixed 2026-06-26 16:30:48 +02:00
caitlin ec8207fca7 Update login page styling and sidebar collapse logo 2026-06-26 16:22:28 +02:00
TroodonEnjoyer ec4de03cf5 Restore bug report button in topbar 2026-06-26 15:32:04 +02:00
Hannah_Bagga dfba947aa4 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 15:22:36 +02:00
TroodonEnjoyer 9bd795e39a Lazy imports for browser-use deps + restore missing bug-report route
browser-use-service/main.py: Made browser_use and langchain_ollama imports lazy (imported only in functions that use them) so the scraper starts without these optional packages.
2026-06-26 15:20:54 +02:00
Hannah_Bagga 66ae711cf0 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
# Conflicts:
#	src/app/(dashboard)/chats/page.tsx
2026-06-26 15:20:42 +02:00
Hannah_Bagga 827b0598e6 voice note and sticker pack and gifs 2026-06-26 15:18:09 +02:00
Ace 650116872b added Chnages on 2026-06-26 14:37:50 +02:00
Chariah 5e3c12fa2d Merge remote-tracking branch 'origin/main' 2026-06-26 14:31:46 +02:00
Chariah 7a76841309 Current state 2026-06-26 14:31:38 +02:00
Chariah 283c06f0cf Merge remote-tracking branch 'origin/main' 2026-06-26 14:25:21 +02:00
Chariah 60892d6151 Current state 2026-06-26 14:24:59 +02:00
Rene 131d4c8f94 Adding Block extensions 2026-06-26 14:23:29 +02:00
Ace 1f448320be Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 13:07:44 +02:00
Ace 9ce9506e8e Update on auto setup 2026-06-26 13:07:40 +02:00
Hannah_Bagga ed2e1fc64d Web Background 2026-06-26 13:03:37 +02:00
Ace 5feb95187c adding a wizard for setup 2026-06-26 12:08:32 +02:00
Ace a4d2ad143b Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 11:48:54 +02:00
Ace da702d6beb Changed from user specific, to open for everyone 2026-06-26 11:48:51 +02:00
caitlin 33a4f9dfa9 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 11:48:02 +02:00
Hannah_Bagga 82e8ce8ae9 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 11:21:45 +02:00
Hannah_Bagga 343f814569 npm 2026-06-26 11:21:10 +02:00
TroodonEnjoyer 4c09635d30 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 11:14:08 +02:00
TroodonEnjoyer 20a1744e7f Security architecture upgrade + bug reporting system
Database & Security:
- Dual password storage: bcrypt (auth) + pgcrypto AES-256 (recovery)
- SUPER_ADMIN master key recovery system (master_keys table)
- Row Level Security on customers, leads, opportunities, communications, tasks
  - SALES_USER: own records only
  - ADMIN: all records
  - SUPER_ADMIN: all records (bypasses RLS)
- Immutable audit logs (DELETE/UPDATE blocked by triggers)
- New audit event types: BUG_CREATED, BUG_UPDATED, BUG_ASSIGNED,
  BUG_RESOLVED, LOGIN, LOGOUT
- Database export logging (database_export_logs table)
- Backup logging with pg_dump script (scripts/backup.ps1)
- Fixed audit constraint to allow new action types

Authentication:
- Random JWT secret generated on every dev server start
  (invalidates all prior sessions after restart)
- Session cookie is now session-only (no maxAge)
- setSessionContext() for RLS integration

Bug Reporting System:
- bug_reports table with RLS (insert by all, select/update by admin only)
- POST /api/bug-reports (any authenticated user)
- GET /api/bug-reports (admin/super_admin only)
- PATCH /api/bug-reports/:id (admin/super_admin only)
- POST /api/auth/recover (super_admin password recovery)
- Audit logging for all bug report actions

Other:
- Added 'dev' to UserRole type
- Bug report modal UI with severity selector
- Added bug report button to topbar
2026-06-26 11:13:28 +02:00
Ace c9df7787d5 Added in loading bot 2026-06-26 11:10:58 +02:00
caitlin fa39f2af9d Merge origin/main into master 2026-06-26 09:48:11 +02:00
Ace 9bbaf70145 Checks done 2026-06-25 16:04:05 +02:00
Hannah_Bagga 5668a63370 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-25 15:50:35 +02:00
Ace 3af622f53d added browser 2026-06-25 15:42:35 +02:00
Hannah_Bagga 1465016b56 ..... 2026-06-25 15:15:35 +02:00
Hannah_Bagga 906e37e845 .... 2026-06-25 15:15:15 +02:00
caitlin 52a489759f Working on the mcp, this helps connect it 2026-06-25 10:25:00 +02:00
Ace 1f032dc098 Added rene changes 2026-06-24 18:43:38 +02:00
Ace 1adb6544c2 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-24 17:35:58 +02:00
Ace 56ff9995a2 Restore file structure to last legit commit b245b35, remove remote CRM_ENVR-main files 2026-06-24 17:35:48 +02:00
TroodonEnjoyer 33753a1291 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-24 17:35:10 +02:00
Ace 06912f3e59 Merge remote changes, keep local project structure 2026-06-24 17:29:07 +02:00
Ace f7258d38f6 Seeing Ai new added 2026-06-24 17:26:32 +02:00
Ace 4c1d994054 fix 2026-06-24 17:21:52 +02:00
Ace b245b352e7 Updated Ai to pull from facebook, added a loading SVG to make user wait for server too boot up, and added commands that can be used to start off the AI exstraction 2026-06-24 17:17:38 +02:00
Rene cd797c5b3c Remove node_modules from tracking 2026-06-24 15:21:57 +02:00
Rene 9d9309833b Add root .gitignore to exclude node_modules and .next 2026-06-24 15:21:49 +02:00
Rene 1e332fb50d Fix JWT_SECRET env var, setup database, update spider web and DAILY BUGLE colors to #e62020 2026-06-24 15:08:48 +02:00
TroodonEnjoyer 9acbbb6a19 Fixed the problem with skipping the login screen and /dashboard 2026-06-24 13:15:04 +02:00
Ace 8a6ad5c6c6 Updated 2026-06-24 12:30:22 +02:00
Ace b2a2f7e40f Ahh fix bloating size upload was to large 2026-06-24 09:54:28 +02:00
Ace 2b4749e5e1 Add rust-ai/target/ to gitignore, remove build artifacts from tracking 2026-06-24 08:58:18 +02:00
Ace e872ab9736 feat: Enhance dashboard UI with Spider-Man theme
- Added Spider-Man themed watermarks and gradients to the dashboard components.
- Updated color scheme for various dashboard elements to match the Spider-Man theme.
- Introduced new styles and animations for loading states and visual elements.
- Refactored existing components to incorporate the new design elements, including charts and cards.
- Updated global styles to support the new theme and added custom fonts.
- Adjusted layout and responsiveness for better user experience.
2026-06-23 20:24:27 +02:00
Hannah_Bagga 4d9a1dc684 Update .gitignore for browser-use-service generated files 2026-06-23 19:57:37 +02:00
Hannah_Bagga 203eac2132 .. 2026-06-23 19:54:46 +02:00
Hannah_Bagga 97e8da3bfd Merge remote changes from origin/main 2026-06-23 19:54:00 +02:00
Hannah_Bagga 3a927b25b6 . 2026-06-23 19:12:10 +02:00
Hannah_Bagga b4be369f25 no clue 2026-06-23 19:11:42 +02:00
Hannah_Bagga 4fd9f7752a spider man theme??? 2026-06-23 19:10:43 +02:00
Hannah_Bagga e78503b5c1 Spider-man theme??? 2026-06-23 19:07:40 +02:00
Ace d51c84997a new changes 2026-06-23 15:29:34 +02:00
Ace c355d0e2e5 Refactor code structure for improved readability and maintainability 2026-06-23 14:25:39 +02:00
Ace ff56cea4b8 feat: add Facebook account tracking and management
- Introduced new migration for `facebook_accounts` and `facebook_scrape_logs` tables.
- Updated the scraping logic to handle Facebook accounts, including success and failure tracking.
- Implemented API endpoints for managing Facebook accounts and their scrape logs.
- Enhanced user permissions to restrict access to Facebook account management to admins and super admins.
- Added a dialog component for displaying and managing Facebook accounts in the UI.
- Updated lead fetching logic to include user role checks for assignment and access.
2026-06-23 14:18:18 +02:00
Hannah_Bagga 7bd9c17b5f Add Node.js AI server, fix dev scripts, ignore rust-ai/target 2026-06-23 11:07:55 +02:00
Hannah_Bagga 1c717ce7ba Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-23 10:07:36 +02:00
caitlin cc56fe6286 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-23 10:01:52 +02:00
Hannah_Bagga cf554a70cd Fix dev:rust to use Node.js AI server (Rust build blocked by Defender), fix dev:ollama fallback path 2026-06-23 10:01:33 +02:00
Hannah_Bagga 3061ab111c Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
# Conflicts:
#	package.json
#	rust-ai/target/.rustc_info.json
#	rust-ai/target/debug/.fingerprint/crm-ai-f6bc192b1f678367/bin-crm-ai
#	rust-ai/target/debug/.fingerprint/crm-ai-f6bc192b1f678367/bin-crm-ai.json
#	rust-ai/target/debug/build/crossbeam-utils-8066693d49c7288a/build-script-build.exe
#	rust-ai/target/debug/build/crossbeam-utils-8066693d49c7288a/build_script_build-8066693d49c7288a.exe
#	rust-ai/target/debug/build/crossbeam-utils-8066693d49c7288a/build_script_build-8066693d49c7288a.pdb
#	rust-ai/target/debug/build/crossbeam-utils-8066693d49c7288a/build_script_build.pdb
#	rust-ai/target/debug/build/generic-array-91b16dd255e0db51/build-script-build.exe
#	rust-ai/target/debug/build/generic-array-91b16dd255e0db51/build_script_build-91b16dd255e0db51.exe
#	rust-ai/target/debug/build/generic-array-91b16dd255e0db51/build_script_build-91b16dd255e0db51.pdb
#	rust-ai/target/debug/build/generic-array-91b16dd255e0db51/build_script_build.pdb
#	rust-ai/target/debug/build/getrandom-2c7c133266bf5cf5/build-script-build.exe
#	rust-ai/target/debug/build/getrandom-2c7c133266bf5cf5/build_script_build-2c7c133266bf5cf5.exe
#	rust-ai/target/debug/build/getrandom-2c7c133266bf5cf5/build_script_build-2c7c133266bf5cf5.pdb
#	rust-ai/target/debug/build/getrandom-2c7c133266bf5cf5/build_script_build.pdb
#	rust-ai/target/debug/build/httparse-4dc4630409fa7385/build-script-build.exe
#	rust-ai/target/debug/build/httparse-4dc4630409fa7385/build_script_build-4dc4630409fa7385.exe
#	rust-ai/target/debug/build/httparse-4dc4630409fa7385/build_script_build-4dc4630409fa7385.pdb
#	rust-ai/target/debug/build/httparse-4dc4630409fa7385/build_script_build.pdb
#	rust-ai/target/debug/build/icu_normalizer_data-96a52fd262d0f4c6/build-script-build.exe
#	rust-ai/target/debug/build/icu_normalizer_data-96a52fd262d0f4c6/build_script_build-96a52fd262d0f4c6.exe
#	rust-ai/target/debug/build/icu_normalizer_data-96a52fd262d0f4c6/build_script_build-96a52fd262d0f4c6.pdb
#	rust-ai/target/debug/build/icu_normalizer_data-96a52fd262d0f4c6/build_script_build.pdb
#	rust-ai/target/debug/build/icu_properties_data-cd63a06a3e531c28/build-script-build.exe
#	rust-ai/target/debug/build/icu_properties_data-cd63a06a3e531c28/build_script_build-cd63a06a3e531c28.exe
#	rust-ai/target/debug/build/icu_properties_data-cd63a06a3e531c28/build_script_build-cd63a06a3e531c28.pdb
#	rust-ai/target/debug/build/icu_properties_data-cd63a06a3e531c28/build_script_build.pdb
#	rust-ai/target/debug/build/libc-763fb040b2d663ab/build-script-build.exe
#	rust-ai/target/debug/build/libc-763fb040b2d663ab/build_script_build-763fb040b2d663ab.exe
#	rust-ai/target/debug/build/libc-763fb040b2d663ab/build_script_build-763fb040b2d663ab.pdb
#	rust-ai/target/debug/build/libc-763fb040b2d663ab/build_script_build.pdb
#	rust-ai/target/debug/build/native-tls-33399c41462f9840/build-script-build.exe
#	rust-ai/target/debug/build/native-tls-33399c41462f9840/build_script_build-33399c41462f9840.exe
#	rust-ai/target/debug/build/native-tls-33399c41462f9840/build_script_build-33399c41462f9840.pdb
#	rust-ai/target/debug/build/native-tls-33399c41462f9840/build_script_build.pdb
#	rust-ai/target/debug/build/num-traits-7471ce6e6595e59b/build-script-build.exe
#	rust-ai/target/debug/build/num-traits-7471ce6e6595e59b/build_script_build-7471ce6e6595e59b.exe
#	rust-ai/target/debug/build/num-traits-7471ce6e6595e59b/build_script_build-7471ce6e6595e59b.pdb
#	rust-ai/target/debug/build/num-traits-7471ce6e6595e59b/build_script_build.pdb
#	rust-ai/target/debug/build/parking_lot_core-12fc7eb0299b59cc/build-script-build.exe
#	rust-ai/target/debug/build/parking_lot_core-12fc7eb0299b59cc/build_script_build-12fc7eb0299b59cc.exe
#	rust-ai/target/debug/build/parking_lot_core-12fc7eb0299b59cc/build_script_build-12fc7eb0299b59cc.pdb
#	rust-ai/target/debug/build/parking_lot_core-12fc7eb0299b59cc/build_script_build.pdb
#	rust-ai/target/debug/build/proc-macro2-f29c3a04cd092e27/build-script-build.exe
#	rust-ai/target/debug/build/proc-macro2-f29c3a04cd092e27/build_script_build-f29c3a04cd092e27.exe
#	rust-ai/target/debug/build/proc-macro2-f29c3a04cd092e27/build_script_build-f29c3a04cd092e27.pdb
#	rust-ai/target/debug/build/proc-macro2-f29c3a04cd092e27/build_script_build.pdb
#	rust-ai/target/debug/build/quote-a01905ec43427c6e/build-script-build.exe
#	rust-ai/target/debug/build/quote-a01905ec43427c6e/build_script_build-a01905ec43427c6e.exe
#	rust-ai/target/debug/build/quote-a01905ec43427c6e/build_script_build-a01905ec43427c6e.pdb
#	rust-ai/target/debug/build/quote-a01905ec43427c6e/build_script_build.pdb
#	rust-ai/target/debug/build/rustversion-f8e8dfefa013a996/build-script-build.exe
#	rust-ai/target/debug/build/rustversion-f8e8dfefa013a996/build_script_build-f8e8dfefa013a996.exe
#	rust-ai/target/debug/build/rustversion-f8e8dfefa013a996/build_script_build-f8e8dfefa013a996.pdb
#	rust-ai/target/debug/build/rustversion-f8e8dfefa013a996/build_script_build.pdb
#	rust-ai/target/debug/build/serde-b4e8599ad30d0584/build-script-build.exe
#	rust-ai/target/debug/build/serde-b4e8599ad30d0584/build_script_build-b4e8599ad30d0584.exe
#	rust-ai/target/debug/build/serde-b4e8599ad30d0584/build_script_build-b4e8599ad30d0584.pdb
#	rust-ai/target/debug/build/serde-b4e8599ad30d0584/build_script_build.pdb
#	rust-ai/target/debug/build/serde_core-fdf5f60f2a35bbe7/build-script-build.exe
#	rust-ai/target/debug/build/serde_core-fdf5f60f2a35bbe7/build_script_build-fdf5f60f2a35bbe7.exe
#	rust-ai/target/debug/build/serde_core-fdf5f60f2a35bbe7/build_script_build-fdf5f60f2a35bbe7.pdb
#	rust-ai/target/debug/build/serde_core-fdf5f60f2a35bbe7/build_script_build.pdb
#	rust-ai/target/debug/build/serde_json-257d8c53e770cd03/build-script-build.exe
#	rust-ai/target/debug/build/serde_json-257d8c53e770cd03/build_script_build-257d8c53e770cd03.exe
#	rust-ai/target/debug/build/serde_json-257d8c53e770cd03/build_script_build-257d8c53e770cd03.pdb
#	rust-ai/target/debug/build/serde_json-257d8c53e770cd03/build_script_build.pdb
#	rust-ai/target/debug/build/thiserror-f1e01e19f7ffccfe/build-script-build.exe
#	rust-ai/target/debug/build/thiserror-f1e01e19f7ffccfe/build_script_build-f1e01e19f7ffccfe.exe
#	rust-ai/target/debug/build/thiserror-f1e01e19f7ffccfe/build_script_build-f1e01e19f7ffccfe.pdb
#	rust-ai/target/debug/build/thiserror-f1e01e19f7ffccfe/build_script_build.pdb
#	rust-ai/target/debug/build/zerocopy-c9c41e065706d9f3/build-script-build.exe
#	rust-ai/target/debug/build/zerocopy-c9c41e065706d9f3/build_script_build-c9c41e065706d9f3.exe
#	rust-ai/target/debug/build/zerocopy-c9c41e065706d9f3/build_script_build-c9c41e065706d9f3.pdb
#	rust-ai/target/debug/build/zerocopy-c9c41e065706d9f3/build_script_build.pdb
#	rust-ai/target/debug/build/zmij-bbaef9d295c9b9a4/build-script-build.exe
#	rust-ai/target/debug/build/zmij-bbaef9d295c9b9a4/build_script_build-bbaef9d295c9b9a4.exe
#	rust-ai/target/debug/build/zmij-bbaef9d295c9b9a4/build_script_build-bbaef9d295c9b9a4.pdb
#	rust-ai/target/debug/build/zmij-bbaef9d295c9b9a4/build_script_build.pdb
#	rust-ai/target/debug/crm-ai.exe
#	rust-ai/target/debug/crm_ai.pdb
#	rust-ai/target/debug/deps/async_trait-68aee6d09cf7ad62.dll
#	rust-ai/target/debug/deps/async_trait-68aee6d09cf7ad62.dll.exp
#	rust-ai/target/debug/deps/async_trait-68aee6d09cf7ad62.pdb
#	rust-ai/target/debug/deps/crm_ai.exe
#	rust-ai/target/debug/deps/crm_ai.pdb
#	rust-ai/target/debug/deps/displaydoc-df5efcb720a2661d.dll
#	rust-ai/target/debug/deps/displaydoc-df5efcb720a2661d.dll.exp
#	rust-ai/target/debug/deps/displaydoc-df5efcb720a2661d.pdb
#	rust-ai/target/debug/deps/rustversion-3bff64c9379d1bfc.dll
#	rust-ai/target/debug/deps/rustversion-3bff64c9379d1bfc.dll.exp
#	rust-ai/target/debug/deps/rustversion-3bff64c9379d1bfc.pdb
#	rust-ai/target/debug/deps/serde_derive-5075d2cdf4f787ec.dll
#	rust-ai/target/debug/deps/serde_derive-5075d2cdf4f787ec.dll.exp
#	rust-ai/target/debug/deps/serde_derive-5075d2cdf4f787ec.pdb
#	rust-ai/target/debug/deps/thiserror_impl-d7f41de49f2cc66d.dll
#	rust-ai/target/debug/deps/thiserror_impl-d7f41de49f2cc66d.dll.exp
#	rust-ai/target/debug/deps/thiserror_impl-d7f41de49f2cc66d.pdb
#	rust-ai/target/debug/deps/tokio_macros-afc286fa0daf0a5b.dll
#	rust-ai/target/debug/deps/tokio_macros-afc286fa0daf0a5b.dll.exp
#	rust-ai/target/debug/deps/tokio_macros-afc286fa0daf0a5b.pdb
#	rust-ai/target/debug/deps/tracing_attributes-c958f756e18a5ca5.dll
#	rust-ai/target/debug/deps/tracing_attributes-c958f756e18a5ca5.dll.exp
#	rust-ai/target/debug/deps/tracing_attributes-c958f756e18a5ca5.pdb
#	rust-ai/target/debug/deps/yoke_derive-1ee9709a84873876.dll
#	rust-ai/target/debug/deps/yoke_derive-1ee9709a84873876.dll.exp
#	rust-ai/target/debug/deps/yoke_derive-1ee9709a84873876.pdb
#	rust-ai/target/debug/deps/zerofrom_derive-84258ff2c2774032.dll
#	rust-ai/target/debug/deps/zerofrom_derive-84258ff2c2774032.dll.exp
#	rust-ai/target/debug/deps/zerofrom_derive-84258ff2c2774032.pdb
#	rust-ai/target/debug/deps/zerovec_derive-ca6ec60ddda5cee1.dll
#	rust-ai/target/debug/deps/zerovec_derive-ca6ec60ddda5cee1.dll.exp
#	rust-ai/target/debug/deps/zerovec_derive-ca6ec60ddda5cee1.pdb
2026-06-23 10:00:28 +02:00
caitlin 491ff52b90 Fixed notifications, It shows when you get get a
message and from who
2026-06-23 09:59:53 +02:00
Ace 1adc4806fa Files Changed
Security Fixes
File	Change
.env.local	Placeholder JWT secret (rotate to random value: openssl rand -base64 64)
src/middleware.ts	Removed /api/auth/me bypass; API routes return JSON 401 (not 302 redirect)
src/lib/auth.ts	sameSite: "lax" → "strict" (create + destroy cookie); SVG name chars are pre-computed (no injection)
src/lib/avatar.ts	Added sanitizeName() — strips non-alphanumeric/safe chars, max 50 chars
src/app/api/users/route.ts	POST restricted to super_admin; validates role against whitelist ["sales","admin","super_admin","dev"]; validates active defaults to true
src/app/api/users/[id]/route.ts	DELETE restricted to super_admin; blocks self-deletion
src/app/api/leads/[id]/route.ts	GET: non-admin users filtered by assigned_to; PATCH: ownership check + score validated 0-100 range
src/app/api/leads/route.ts	DELETE: ownership + admin check; GET: ownership filter for non-admin users
src/app/api/conversations/[id]/messages/route.ts	GET/POST: verify user is a conversation_participant before access
rust-ai/src/main.rs	CORS: AllowOrigin::list(["localhost:3006", "127.0.0.1:3006"]) (was Any)
Performance Fixes
File	Change
src/app/api/leads/route.ts	Added limit (default 50), offset query params; ownership filter in SQL
src/app/api/users/route.ts	Added limit (default 50), offset query params
src/app/api/conversations/route.ts	Added LIMIT 50
src/app/api/leads/[id]/notes/route.ts	Added limit (default 50), offset query params
src/app/api/conversations/[id]/messages/route.ts	Added limit (default 100), offset, before query params
src/app/(dashboard)/chats/page.tsx	Pruned to 5 cached conversations; useMemo wraps messages/conversation/filteredConversations; otherParticipant moved outside component; added 10MB file size limit; recordingChunksRef cleared on discard
src/app/(dashboard)/leads/[id]/page.tsx	Optimistic status update rolls back on fetch failure
database/migrations/003_chat.sql	Added composite index idx_messages_conversation_created on (conversation_id, created_at DESC)
Code Quality Fixes
File	Change
src/lib/ai.ts	Removed dead getInstructions()/updateInstructions() (called nonexistent /ai/instructions)
src/lib/auth.ts	bcrypt.hash(password, 12) — kept (acceptable, OWASP-recommended)
src/app/login/page.tsx	"Forgot password?" button now shows alert to contact admin (was no-op)
src/components/users/user-form-dialog.tsx	Password min 6 → 8 chars
26 silent catch {} blocks across 16 files (see below)	Added console.warn("...") context messages
2026-06-23 09:45:30 +02:00
Ace d891197d8b Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-22 22:02:43 +02:00
Ace b81f391df5 Got facebook to work test tomorrow with new accounts please 2026-06-22 22:00:56 +02:00
Hannah_Bagga 9cbae2022b Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
# Conflicts:
#	rust-ai/target/debug/crm-ai.exe
#	rust-ai/target/debug/crm_ai.pdb
#	rust-ai/target/debug/deps/crm_ai.exe
#	rust-ai/target/debug/deps/crm_ai.pdb
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/028yt4653ytvwtc2zr0yqrn6d.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/02g2q20ttno4r70g3sm7bhrkl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/04d6410k8llyw7zqiplfptvh4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/04nljec5a29x85ly2o3vrtwaw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/05x3ko8dwspamszih9syu54g6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/08v9r75i2w9a389q4xezh88y1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0c2e2wk33ohiwgxfhdiuv0p0q.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0f0gi1quvn99fpae5wxodltis.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0hdhmkafn64deyd5v02x318rt.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0iaomc3lcl3ka6zwtqlotplk4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0ijm8sm23h61y7jamxx0qo7uf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0jzjpmz2vgeutoux95g9jzcxf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0ov0709m5f5g4qwbj9cjx861f.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0p6rplx683h34xa057913j86q.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0q8mlwutzsm25nugo52imgmpk.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0r1iwol49qmn4xw9kizdiusw9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0symkw0vjvcmyiq4rpwjzt4nn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0tf15a3xqr1e95uhuc5geesvg.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0v9tk0mf79g7th1bbp5yiw9c9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0vhxwriugc4hlj8pfd7tx5vx9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0wnq26nwqny7plzycbmjke1ko.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/0zpre0e5r191b8oc07tfb94tr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/11da2n9qyrhhxlq3ukwstdb8p.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/16g8cbeme4gf6csftsqc31qxw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/17ccf4apkn2aajxsrtx0lzqq0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/17t8qpdp6m1jm8aappsvdot1b.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/193uw1ygp5pha51loya170phx.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/19di9ofb84qpqfz830ai0ers8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/19w3jgflkbl1ojltpouoapgt7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/19ym1rn8bgi8x3xolo9indhxq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1ac4koooa55bvzcffe40ni6oh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1an0oofqnuj2cjrztrw9yi3ro.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1cw2wjrj8okklktqn2l24bqe8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1e8gpdh5x4m45sjgfrgi863u7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1fx097hkmddp6k7tammeddalc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1ipeucm9w7eevrsetadex4fpm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1koprg0kbjultvf8qpyidm48s.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1o5gkmbse9tlychb3h9g4baxq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1pllgjkdhg1kiptmvr5frmqm7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1pv13t75uryo8b0x1626x0myb.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1rr2pzpofrr4tpc9ca9dv6iuc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1rztm62v4t8p5f2naprqa2lti.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/1wz4fdpsjfkn88pifsjloi09q.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/20qxccwcqeqmp59md8yxuvh2v.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/20vsyncng2b713qjs1gnj29vc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/26ux44slkez148zlnyupjmb92.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/26yedftm0tsuompma7jn4n3zb.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/2937acu2vhck6giqrrs00606b.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/2d5v58t59h1n2zwh9a6ftbz41.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/2hpao3k1bf99jn6whkdntu8y7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/2l9sody5gkgsdtgivhyv9bw5c.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/2s196yj64k717kklqsxcrllbr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/2s1w14m8afpnvblsxk6vg3idw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/2skj3kpf638i7dqefhdv36kos.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/2tt1vfp6hzwtp7vi3nhcxuute.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/2tuexier60nj2f9twey7um29r.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/2xbasgqzhdwlfkvg9s9doqjy1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/2xfu20p8fjt502uoc8h8ah241.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/30ike1y02byxaxt8mwv63qoua.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/3142nsun95nq41wgw69w13ihi.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/33dst540joac6rravhhxjzdzx.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/34pzbtmmge3dcoio5xi2kugcz.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/36rd3wtav43hkdqe5rdgolu2m.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/3a9lltnwu3r77v24u269727eo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/3byjnlifs8g4d3id3jkfv8ctk.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/3rfklzx7zv6lxsy53cdng578p.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/3s7mm9hfkv2w9zbp2flokupwf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/3v6o1fzt35ze2y04qjuzqs2sr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/3wnlg1ghhx2dyq11fq8iimr5o.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4404h5d0dtk90xxnepwg434v0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/44uypfnht5vn54bwlkzkx02dg.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/48b4ftesu7v1fvhjlpk1n9dtv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4brrzo036zv56d7wizedkr2z6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4eq65cad4lecm6z91mpk3f3xs.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4g9l9ttupz9norbkspl35kamo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4nucap8g4jj9ps2ezujaqtbfd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4o7c6ihwtrabd13rfnj4q7yo3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4ojrjnyszfa5t7fqf0bhmxiw5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4p65uaznrrixtx1xx23pllzfh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4qbim1856byp76ie5wt9o6wgv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4qs4w8si7hb7qhjp84a3duhxr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4s9w5j2dc8ilq4tixfhaqs5dh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4tpn7kbohxzqts20hlfqtgh4c.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4v7och0lm6ky559ug04ekm2wt.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4w7ehbdr9gg4kdkl1tox5l9mn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/4xpzyvemjzwci5ms2tlvmt2t5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/58bpu9zpiiwrnabqqdfcli5pb.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/59l0a9ptvdqskfs0b0eyfxvwa.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/5aue478igcutten2mmyewfcgu.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/5dfiu908y5h7mb55pxgjfyk0v.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/5e0918ct7qhwjogtmgq07yi7n.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/5fq0ugz1v59pl246ein9a9frs.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/5p9zb3spkd82yv8mt5z3xvelt.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/5t54obf57qzqzwxm4zfska1lu.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/5yb3ff2gziejszjraai4ciwq0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/5yjqjmrododoue946j39jmwfc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/5yl5vcg3vjtphnvrsrktsyurz.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/5zpcmanilt8e2i0yek1cuqd7e.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/617njvr91ijbhhc0389e65zfd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/65pcak4ic9ghdchgfvzxukfwm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6chla0dfaqu6ko1ed4g5k0bh0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6dha81kzsqapq01tfd1ffhn5t.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6dwliga6ntx8akhuepotm02li.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6eeyt2onsz4l0rn33i4n314x7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6gdktngd679y245srd3v2u6j1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6hrpu48xpvotylm8qqzlek6pe.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6i2tf5fogqrefbtk2k0fts08v.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6ibg4ld8tp7hsdf7vp08zfkv9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6jvbdq95re5su1n4fg6dvsxny.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6l0x7rweo4qd11s9f7wd1fnkn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6ql1fqbod42inazne8so3r6nq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6tvxdiznwcf9qr6zr7t086222.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/6u4nbzagid291wdm8eld817sn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/73pr4riia6fm36v2mv8ualvka.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/74cgx3dwb9sdxhksblzagmgs9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/76lc81i63omemb4kbapvkca61.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/77ez5zdjkfv1zyb8kv2947iyt.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/77n53sp5bl9wwwv4jl8coksgc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/79208bu4cupe8s8ag77748np0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/7dzxkv9xf71qotmq14g7qipu3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/7enne7dwhbn9ouo4nat6p64y7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/7ifcfokmc84o6qcxxle9rrakh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/7k6mmlbc5ftl68d8ps1umrvop.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/7pmvgu8la26u135mfw40eq3pz.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/7pudfjwumqe0op1k7lbc9s305.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/7qge0m80mpin4pqsga7rx6vd2.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/7qnf98333jk7yfs3fspeve0q3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/7si0wz8kjxdid6s2fbs5ftinw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/7wbimrjjl3fezkh4fn8fqhtbe.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/7zoldj2kvzvtu6ofzkvjt1vzj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/814nro5tv6adkg94g0gissjx0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/851cadq8cj4zqgfig20ibrwlk.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/85vmp0djfzbgzc9sk1b9lqzdf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/86o8ak15n4yxwno3ci8cqr3ew.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/8e4dl0pzxkrepa828oby35lt0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/8ecjfrpqv7uw1w57uleobc8gr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/8gnehf271hrk9kykwm93jc6nb.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/8gnm0fpqfspyxqpyra980atnk.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/8h0jc5w6powgi0c8vn1x4dan8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/8i51mbqp49c9ugk5c7x80cd91.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/8ju8n2tsmlect8qpai3uik6r0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/8kp9g30qj1rpiqzt02hv8tuu8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/8lz3p6ny90b4rlpfrhemq20u3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/8m6yim3wrm4bwpwnpyf2mtjcm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/8oza8lu87mxnvp7ym38s0g7zo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/8qrm1j72swz03z7ynlu6yoirv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/904tkn6xn0ms6a1uh0r8yr7c2.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/91vwuknyr86zvl3l8z3vy746m.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/933muzlqc5yxbghdebkf6avry.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/934fq4mkaerkyuisnny7ioqlw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/95ljs53b24a70l0te2ld4s31r.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/95lo1abl1kxmuzj7ncapojddm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/966mezbhzmqio8wd2b3ajd6tf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/97uj60t48p2f63l0sh5e6d7jc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/98435dxc5aa2rn05pq2ewvsrp.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/98oj9sr8guqc7dpmiywbwkii3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/996qyo133d65sqx9xt231jq6r.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/99dqih93zdsfr60bal5e2p2gi.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9dwlw011jr5607lwvmba9451j.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9eyyyddscolgdirto5lbpczmj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9jg91l3p6vhajyh77dxls6fcq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9nq5bobnq5g7fcb42y8ty6etc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9ovt5zpsr2epwp0gi3il8iytl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9pnc9z4av5hm4ea25vlz4xqsu.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9rsps4hv0b3oliyfzytnl6pcq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9t90e7028ttpxafof3hkpbw75.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9tw92rn8rt9gc2zqs76pk1om8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9vkg1eytunhbsohqqngxrggo5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9vu9qc8ysylnxdqqdh8a79r9k.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9w23rwzl79tncp0og81fl9mbj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9wlug478tatatqr8mvtveqjx4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/9wpqitn8gn028w639ke7tscvn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/a1777q6ws3xopddbts44rj32s.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/a2k3qlloqq55pfe59cf1dw6rl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/a4gqibcoojzeh7my9rjjlsrsq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/a4vvbuu4ncozciazbzl1i8zgd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/a6wxbk7zenr2hutjts01e6iov.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/a7cx0yqc6gzx2wnalp130o4zo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/a7v64mm0t5svnom84kyjxpy6b.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/ae4ntfvninsr2feomftjf90vl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/ahwroicqyfe1os20j7ev1sx92.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/anx411kz78bpo2cywd5890o5g.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/ao3bgk0tppun9724prghwlyaw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/aptfmhdofankym4ydqcilebt4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/as2fwt6olfgi6jb4k2amz7ts7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/av5atcjkr4wcy3136kpaozqmd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/avatzw7dqxkzql5nbftropvok.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/avh6oyj7x9fzchqngmufcmfxl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/avq2q54guy470vpthy8xekyb3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/axxc02oln41z0uowdnn637xeh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/aymzk97ygih4m8kyminhyxpt6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/b4gj43t9hkoxfowc43a749l9j.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/b5or5vxif2f8blj3ob77tb24z.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/b60sxwxw0o2ylu7jzax5k0rf8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/b98kusc5t91zip4xkkb4xq3h1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/b9gmceyqj8dvxgy4y9rho4f6f.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/be58ksg0xcmvouu8jdu7ku62a.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/bf7fhjb5yslr2f68zebwopmyo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/bih7nsbedfkdq1egfrs2eosec.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/bkp6e0xmkndnrfozeojdiqke7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/bmg2ckiwaw53e3uwi5ohzhta9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/bro5iu13s0u262x7c0jbin4kd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/bs45flxj4pgfw49i4q7o3tndv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/btrv9t0byeqthu6z0breyxfns.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/bzf456z8sywkfhdlyqxxn1pko.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/c0ln86pcd9vjhnujjfz3qxuc6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/c0ww24nyysdf0e136ubn5r02e.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/c2q5ct4feeb9e3vfnuize1nf8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/c4pskjp43r5bgyamxyozunso6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/cb0rqc2rt9eu5puq2a16hxd2m.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/cdszqc7973d6ctu39nrp1smua.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/cech6pkto4risvcalhoa6fhoj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/cj3pqf8ctvl4w48ziqf8n23x4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/co6e78io5knk3p1hrqwi845jr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/crj7k979kc8xd5cwcqjll6y8h.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/ct5tx4jcgdi9fm52l8zadz4t1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/ct7burgpfko9hflp1y63sqfz0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/czvn67h4gqbefj0zvx6uf8d17.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/d0n01icjvhek62d0qzj34kq8t.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/d13r4esoekm2lkqm7w3p1w0bv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/d2yh5h5og1qvbsr3lojzd9yk3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/d31zb3ij77b5hr1mmgcmngbgr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/d5gbhyb9pph1lpm1jt0on2r6g.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/d6aeidzpukgoerv43g3zqo4sy.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/daag5kuru4m55iiofl7ip7w4i.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/dc6sckox1ypwkn2mppc90ud74.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/dep-graph.bin
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/deqdwclxmpdz5piqvzutwscpg.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/df6f8dr5ct7epsam22we32vw6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/dh9jqj2rm8e4rshzxuglhedsf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/dhy5blv8scs0bl7esfxufbkyo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/dkipqw38umlarkzecanc8qu5y.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/dlp0a6ydnnnped0nhs3rjpt8v.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/drhsr5ravh4ue0q51qhh5a48h.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/dtqnos2cqx4l8kq31x07rub4j.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/dvggmkioolno1uygkxip3vw61.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/dwuftppkcwreuiybdtaet4t6i.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/e75iq1ogwrzlfh0t1gjkr1fwm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/ebykw8su7ejmpbclf7la10hts.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/efbgk5tw3ki9iu3iyrzqqnalu.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/ejike8sqb267nzu63vfrygj10.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/ekz8zuk9efsff6wfazktchn6f.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/eltrapn5jhodszwme04ztf0gy.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/em7117y9h7qw16aejmuv5fcr5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/en1xdikii9cox4n9l2gib4wfn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/enjz5e38i8yyuk6hurs6oftsw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/enknqn2imsl5isq96vzz8rtgw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/epei7l7tnqzlsth330qwxqf1b.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/eqpoa8rcel4hfr8llhvv9o86r.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/es8bkgyag3zgza0q42qtnw5fg.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/eu8gkgxuwar6qc4q47dw5qcs5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/euki0c9lb7s1g9bs0ewspcc48.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/evvbsd35evv83bvtw1pkewj2p.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/eyyalb83667luca561uj187pj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/f3sbzjtlib0iiedh5w4jy5zmz.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/f4e91hmjayx6ii7qh2vs8pv5q.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/f4h8cz9aq8r8zfzgxz10fh16p.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/query-cache.bin
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpkbgtw97-1w0jd0a-5l60sr5te7upw1ped9k2ir8qj/work-products.bin
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/028yt4653ytvwtc2zr0yqrn6d.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/02g2q20ttno4r70g3sm7bhrkl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/04d6410k8llyw7zqiplfptvh4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/04nljec5a29x85ly2o3vrtwaw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/05x3ko8dwspamszih9syu54g6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/08v9r75i2w9a389q4xezh88y1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0c2e2wk33ohiwgxfhdiuv0p0q.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0f0gi1quvn99fpae5wxodltis.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0hdhmkafn64deyd5v02x318rt.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0iaomc3lcl3ka6zwtqlotplk4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0ijm8sm23h61y7jamxx0qo7uf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0jzjpmz2vgeutoux95g9jzcxf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0ov0709m5f5g4qwbj9cjx861f.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0p6rplx683h34xa057913j86q.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0q8mlwutzsm25nugo52imgmpk.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0r1iwol49qmn4xw9kizdiusw9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0symkw0vjvcmyiq4rpwjzt4nn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0tf15a3xqr1e95uhuc5geesvg.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0v9tk0mf79g7th1bbp5yiw9c9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0vhxwriugc4hlj8pfd7tx5vx9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0wnq26nwqny7plzycbmjke1ko.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/0zpre0e5r191b8oc07tfb94tr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/11da2n9qyrhhxlq3ukwstdb8p.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/16g8cbeme4gf6csftsqc31qxw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/17ccf4apkn2aajxsrtx0lzqq0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/17t8qpdp6m1jm8aappsvdot1b.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/193uw1ygp5pha51loya170phx.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/19di9ofb84qpqfz830ai0ers8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/19w3jgflkbl1ojltpouoapgt7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/19ym1rn8bgi8x3xolo9indhxq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1ac4koooa55bvzcffe40ni6oh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1an0oofqnuj2cjrztrw9yi3ro.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1cw2wjrj8okklktqn2l24bqe8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1e8gpdh5x4m45sjgfrgi863u7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1fx097hkmddp6k7tammeddalc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1ipeucm9w7eevrsetadex4fpm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1koprg0kbjultvf8qpyidm48s.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1o5gkmbse9tlychb3h9g4baxq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1pllgjkdhg1kiptmvr5frmqm7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1pv13t75uryo8b0x1626x0myb.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1rr2pzpofrr4tpc9ca9dv6iuc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1rztm62v4t8p5f2naprqa2lti.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/1wz4fdpsjfkn88pifsjloi09q.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/20qxccwcqeqmp59md8yxuvh2v.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/20vsyncng2b713qjs1gnj29vc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/26ux44slkez148zlnyupjmb92.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/26yedftm0tsuompma7jn4n3zb.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/2937acu2vhck6giqrrs00606b.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/2d5v58t59h1n2zwh9a6ftbz41.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/2hpao3k1bf99jn6whkdntu8y7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/2l9sody5gkgsdtgivhyv9bw5c.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/2s196yj64k717kklqsxcrllbr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/2s1w14m8afpnvblsxk6vg3idw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/2skj3kpf638i7dqefhdv36kos.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/2tt1vfp6hzwtp7vi3nhcxuute.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/2tuexier60nj2f9twey7um29r.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/2xbasgqzhdwlfkvg9s9doqjy1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/2xfu20p8fjt502uoc8h8ah241.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/30ike1y02byxaxt8mwv63qoua.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/3142nsun95nq41wgw69w13ihi.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/33dst540joac6rravhhxjzdzx.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/34pzbtmmge3dcoio5xi2kugcz.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/36rd3wtav43hkdqe5rdgolu2m.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/3a9lltnwu3r77v24u269727eo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/3byjnlifs8g4d3id3jkfv8ctk.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/3rfklzx7zv6lxsy53cdng578p.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/3s7mm9hfkv2w9zbp2flokupwf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/3v6o1fzt35ze2y04qjuzqs2sr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/3wnlg1ghhx2dyq11fq8iimr5o.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4404h5d0dtk90xxnepwg434v0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/44uypfnht5vn54bwlkzkx02dg.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/48b4ftesu7v1fvhjlpk1n9dtv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4brrzo036zv56d7wizedkr2z6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4eq65cad4lecm6z91mpk3f3xs.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4g9l9ttupz9norbkspl35kamo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4nucap8g4jj9ps2ezujaqtbfd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4o7c6ihwtrabd13rfnj4q7yo3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4ojrjnyszfa5t7fqf0bhmxiw5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4p65uaznrrixtx1xx23pllzfh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4qbim1856byp76ie5wt9o6wgv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4qs4w8si7hb7qhjp84a3duhxr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4s9w5j2dc8ilq4tixfhaqs5dh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4tpn7kbohxzqts20hlfqtgh4c.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4v7och0lm6ky559ug04ekm2wt.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4w7ehbdr9gg4kdkl1tox5l9mn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/4xpzyvemjzwci5ms2tlvmt2t5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/58bpu9zpiiwrnabqqdfcli5pb.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/59l0a9ptvdqskfs0b0eyfxvwa.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/5aue478igcutten2mmyewfcgu.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/5dfiu908y5h7mb55pxgjfyk0v.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/5e0918ct7qhwjogtmgq07yi7n.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/5fq0ugz1v59pl246ein9a9frs.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/5p9zb3spkd82yv8mt5z3xvelt.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/5t54obf57qzqzwxm4zfska1lu.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/5yb3ff2gziejszjraai4ciwq0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/5yjqjmrododoue946j39jmwfc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/5yl5vcg3vjtphnvrsrktsyurz.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/5zpcmanilt8e2i0yek1cuqd7e.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/617njvr91ijbhhc0389e65zfd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/65pcak4ic9ghdchgfvzxukfwm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6chla0dfaqu6ko1ed4g5k0bh0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6dha81kzsqapq01tfd1ffhn5t.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6dwliga6ntx8akhuepotm02li.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6eeyt2onsz4l0rn33i4n314x7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6gdktngd679y245srd3v2u6j1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6hrpu48xpvotylm8qqzlek6pe.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6i2tf5fogqrefbtk2k0fts08v.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6ibg4ld8tp7hsdf7vp08zfkv9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6jvbdq95re5su1n4fg6dvsxny.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6l0x7rweo4qd11s9f7wd1fnkn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6ql1fqbod42inazne8so3r6nq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6tvxdiznwcf9qr6zr7t086222.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/6u4nbzagid291wdm8eld817sn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/73pr4riia6fm36v2mv8ualvka.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/74cgx3dwb9sdxhksblzagmgs9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/76lc81i63omemb4kbapvkca61.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/77ez5zdjkfv1zyb8kv2947iyt.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/77n53sp5bl9wwwv4jl8coksgc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/79208bu4cupe8s8ag77748np0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/7dzxkv9xf71qotmq14g7qipu3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/7enne7dwhbn9ouo4nat6p64y7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/7ifcfokmc84o6qcxxle9rrakh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/7k6mmlbc5ftl68d8ps1umrvop.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/7pmvgu8la26u135mfw40eq3pz.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/7pudfjwumqe0op1k7lbc9s305.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/7qge0m80mpin4pqsga7rx6vd2.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/7qnf98333jk7yfs3fspeve0q3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/7si0wz8kjxdid6s2fbs5ftinw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/7wbimrjjl3fezkh4fn8fqhtbe.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/7zoldj2kvzvtu6ofzkvjt1vzj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/814nro5tv6adkg94g0gissjx0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/851cadq8cj4zqgfig20ibrwlk.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/85vmp0djfzbgzc9sk1b9lqzdf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/86o8ak15n4yxwno3ci8cqr3ew.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/8e4dl0pzxkrepa828oby35lt0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/8ecjfrpqv7uw1w57uleobc8gr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/8gnehf271hrk9kykwm93jc6nb.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/8gnm0fpqfspyxqpyra980atnk.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/8h0jc5w6powgi0c8vn1x4dan8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/8i51mbqp49c9ugk5c7x80cd91.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/8ju8n2tsmlect8qpai3uik6r0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/8kp9g30qj1rpiqzt02hv8tuu8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/8lz3p6ny90b4rlpfrhemq20u3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/8m6yim3wrm4bwpwnpyf2mtjcm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/8oza8lu87mxnvp7ym38s0g7zo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/8qrm1j72swz03z7ynlu6yoirv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/904tkn6xn0ms6a1uh0r8yr7c2.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/91vwuknyr86zvl3l8z3vy746m.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/933muzlqc5yxbghdebkf6avry.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/934fq4mkaerkyuisnny7ioqlw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/95ljs53b24a70l0te2ld4s31r.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/95lo1abl1kxmuzj7ncapojddm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/966mezbhzmqio8wd2b3ajd6tf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/97uj60t48p2f63l0sh5e6d7jc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/98435dxc5aa2rn05pq2ewvsrp.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/98oj9sr8guqc7dpmiywbwkii3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/996qyo133d65sqx9xt231jq6r.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/99dqih93zdsfr60bal5e2p2gi.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9dwlw011jr5607lwvmba9451j.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9eyyyddscolgdirto5lbpczmj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9jg91l3p6vhajyh77dxls6fcq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9nq5bobnq5g7fcb42y8ty6etc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9ovt5zpsr2epwp0gi3il8iytl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9pnc9z4av5hm4ea25vlz4xqsu.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9rsps4hv0b3oliyfzytnl6pcq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9t90e7028ttpxafof3hkpbw75.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9tw92rn8rt9gc2zqs76pk1om8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9vkg1eytunhbsohqqngxrggo5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9vu9qc8ysylnxdqqdh8a79r9k.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9w23rwzl79tncp0og81fl9mbj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9wlug478tatatqr8mvtveqjx4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/9wpqitn8gn028w639ke7tscvn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/a1777q6ws3xopddbts44rj32s.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/a2k3qlloqq55pfe59cf1dw6rl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/a4gqibcoojzeh7my9rjjlsrsq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/a4vvbuu4ncozciazbzl1i8zgd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/a6wxbk7zenr2hutjts01e6iov.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/a7cx0yqc6gzx2wnalp130o4zo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/a7v64mm0t5svnom84kyjxpy6b.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/ae4ntfvninsr2feomftjf90vl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/ahwroicqyfe1os20j7ev1sx92.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/anx411kz78bpo2cywd5890o5g.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/ao3bgk0tppun9724prghwlyaw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/aptfmhdofankym4ydqcilebt4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/as2fwt6olfgi6jb4k2amz7ts7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/av5atcjkr4wcy3136kpaozqmd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/avatzw7dqxkzql5nbftropvok.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/avh6oyj7x9fzchqngmufcmfxl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/avq2q54guy470vpthy8xekyb3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/axxc02oln41z0uowdnn637xeh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/aymzk97ygih4m8kyminhyxpt6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/b4gj43t9hkoxfowc43a749l9j.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/b5or5vxif2f8blj3ob77tb24z.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/b60sxwxw0o2ylu7jzax5k0rf8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/b98kusc5t91zip4xkkb4xq3h1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/b9gmceyqj8dvxgy4y9rho4f6f.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/be58ksg0xcmvouu8jdu7ku62a.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/bf7fhjb5yslr2f68zebwopmyo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/bih7nsbedfkdq1egfrs2eosec.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/bkp6e0xmkndnrfozeojdiqke7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/bmg2ckiwaw53e3uwi5ohzhta9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/bro5iu13s0u262x7c0jbin4kd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/bs45flxj4pgfw49i4q7o3tndv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/btrv9t0byeqthu6z0breyxfns.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/bzf456z8sywkfhdlyqxxn1pko.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/c0ln86pcd9vjhnujjfz3qxuc6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/c0ww24nyysdf0e136ubn5r02e.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/c2q5ct4feeb9e3vfnuize1nf8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/c4pskjp43r5bgyamxyozunso6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/cb0rqc2rt9eu5puq2a16hxd2m.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/cdszqc7973d6ctu39nrp1smua.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/cech6pkto4risvcalhoa6fhoj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/cj3pqf8ctvl4w48ziqf8n23x4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/co6e78io5knk3p1hrqwi845jr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/crj7k979kc8xd5cwcqjll6y8h.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/ct5tx4jcgdi9fm52l8zadz4t1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/ct7burgpfko9hflp1y63sqfz0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/czvn67h4gqbefj0zvx6uf8d17.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/d0n01icjvhek62d0qzj34kq8t.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/d13r4esoekm2lkqm7w3p1w0bv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/d2yh5h5og1qvbsr3lojzd9yk3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/d31zb3ij77b5hr1mmgcmngbgr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/d5gbhyb9pph1lpm1jt0on2r6g.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/d6aeidzpukgoerv43g3zqo4sy.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/daag5kuru4m55iiofl7ip7w4i.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/dc6sckox1ypwkn2mppc90ud74.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/dep-graph.bin
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/deqdwclxmpdz5piqvzutwscpg.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/df6f8dr5ct7epsam22we32vw6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/dh9jqj2rm8e4rshzxuglhedsf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/dhy5blv8scs0bl7esfxufbkyo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/dkipqw38umlarkzecanc8qu5y.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/dlp0a6ydnnnped0nhs3rjpt8v.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/drhsr5ravh4ue0q51qhh5a48h.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/dtqnos2cqx4l8kq31x07rub4j.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/dvggmkioolno1uygkxip3vw61.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/dwuftppkcwreuiybdtaet4t6i.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/e75iq1ogwrzlfh0t1gjkr1fwm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/ebykw8su7ejmpbclf7la10hts.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/efbgk5tw3ki9iu3iyrzqqnalu.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/ejike8sqb267nzu63vfrygj10.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/ekz8zuk9efsff6wfazktchn6f.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/eltrapn5jhodszwme04ztf0gy.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/em7117y9h7qw16aejmuv5fcr5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/en1xdikii9cox4n9l2gib4wfn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/enjz5e38i8yyuk6hurs6oftsw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/enknqn2imsl5isq96vzz8rtgw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/epei7l7tnqzlsth330qwxqf1b.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/eqpoa8rcel4hfr8llhvv9o86r.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/es8bkgyag3zgza0q42qtnw5fg.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/eu8gkgxuwar6qc4q47dw5qcs5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/euki0c9lb7s1g9bs0ewspcc48.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/evvbsd35evv83bvtw1pkewj2p.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/eyyalb83667luca561uj187pj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/f3sbzjtlib0iiedh5w4jy5zmz.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/f4e91hmjayx6ii7qh2vs8pv5q.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/f4h8cz9aq8r8zfzgxz10fh16p.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/query-cache.bin
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpmdmrlko-1h7f07b-a2qmvenj8c4dxc6wvhxe599cg/work-products.bin
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/028yt4653ytvwtc2zr0yqrn6d.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/02g2q20ttno4r70g3sm7bhrkl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/04d6410k8llyw7zqiplfptvh4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/04nljec5a29x85ly2o3vrtwaw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/05x3ko8dwspamszih9syu54g6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/08v9r75i2w9a389q4xezh88y1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0c2e2wk33ohiwgxfhdiuv0p0q.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0f0gi1quvn99fpae5wxodltis.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0hdhmkafn64deyd5v02x318rt.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0iaomc3lcl3ka6zwtqlotplk4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0ijm8sm23h61y7jamxx0qo7uf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0jzjpmz2vgeutoux95g9jzcxf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0ov0709m5f5g4qwbj9cjx861f.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0p6rplx683h34xa057913j86q.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0q8mlwutzsm25nugo52imgmpk.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0r1iwol49qmn4xw9kizdiusw9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0symkw0vjvcmyiq4rpwjzt4nn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0tf15a3xqr1e95uhuc5geesvg.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0v9tk0mf79g7th1bbp5yiw9c9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0vhxwriugc4hlj8pfd7tx5vx9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0wnq26nwqny7plzycbmjke1ko.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/0zpre0e5r191b8oc07tfb94tr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/11da2n9qyrhhxlq3ukwstdb8p.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/16g8cbeme4gf6csftsqc31qxw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/17ccf4apkn2aajxsrtx0lzqq0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/17t8qpdp6m1jm8aappsvdot1b.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/193uw1ygp5pha51loya170phx.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/19di9ofb84qpqfz830ai0ers8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/19w3jgflkbl1ojltpouoapgt7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/19ym1rn8bgi8x3xolo9indhxq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1ac4koooa55bvzcffe40ni6oh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1an0oofqnuj2cjrztrw9yi3ro.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1cw2wjrj8okklktqn2l24bqe8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1e8gpdh5x4m45sjgfrgi863u7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1fx097hkmddp6k7tammeddalc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1ipeucm9w7eevrsetadex4fpm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1koprg0kbjultvf8qpyidm48s.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1o5gkmbse9tlychb3h9g4baxq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1pllgjkdhg1kiptmvr5frmqm7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1pv13t75uryo8b0x1626x0myb.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1rr2pzpofrr4tpc9ca9dv6iuc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1rztm62v4t8p5f2naprqa2lti.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/1wz4fdpsjfkn88pifsjloi09q.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/20qxccwcqeqmp59md8yxuvh2v.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/20vsyncng2b713qjs1gnj29vc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/26ux44slkez148zlnyupjmb92.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/26yedftm0tsuompma7jn4n3zb.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/2937acu2vhck6giqrrs00606b.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/2d5v58t59h1n2zwh9a6ftbz41.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/2hpao3k1bf99jn6whkdntu8y7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/2l9sody5gkgsdtgivhyv9bw5c.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/2s196yj64k717kklqsxcrllbr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/2s1w14m8afpnvblsxk6vg3idw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/2skj3kpf638i7dqefhdv36kos.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/2tt1vfp6hzwtp7vi3nhcxuute.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/2tuexier60nj2f9twey7um29r.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/2xbasgqzhdwlfkvg9s9doqjy1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/2xfu20p8fjt502uoc8h8ah241.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/30ike1y02byxaxt8mwv63qoua.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/3142nsun95nq41wgw69w13ihi.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/33dst540joac6rravhhxjzdzx.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/34pzbtmmge3dcoio5xi2kugcz.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/36rd3wtav43hkdqe5rdgolu2m.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/3a9lltnwu3r77v24u269727eo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/3byjnlifs8g4d3id3jkfv8ctk.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/3rfklzx7zv6lxsy53cdng578p.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/3s7mm9hfkv2w9zbp2flokupwf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/3v6o1fzt35ze2y04qjuzqs2sr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/3wnlg1ghhx2dyq11fq8iimr5o.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4404h5d0dtk90xxnepwg434v0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/44uypfnht5vn54bwlkzkx02dg.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/48b4ftesu7v1fvhjlpk1n9dtv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4brrzo036zv56d7wizedkr2z6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4eq65cad4lecm6z91mpk3f3xs.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4g9l9ttupz9norbkspl35kamo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4nucap8g4jj9ps2ezujaqtbfd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4o7c6ihwtrabd13rfnj4q7yo3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4ojrjnyszfa5t7fqf0bhmxiw5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4p65uaznrrixtx1xx23pllzfh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4qbim1856byp76ie5wt9o6wgv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4qs4w8si7hb7qhjp84a3duhxr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4s9w5j2dc8ilq4tixfhaqs5dh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4tpn7kbohxzqts20hlfqtgh4c.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4v7och0lm6ky559ug04ekm2wt.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4w7ehbdr9gg4kdkl1tox5l9mn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/4xpzyvemjzwci5ms2tlvmt2t5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/58bpu9zpiiwrnabqqdfcli5pb.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/59l0a9ptvdqskfs0b0eyfxvwa.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/5aue478igcutten2mmyewfcgu.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/5dfiu908y5h7mb55pxgjfyk0v.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/5e0918ct7qhwjogtmgq07yi7n.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/5fq0ugz1v59pl246ein9a9frs.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/5p9zb3spkd82yv8mt5z3xvelt.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/5t54obf57qzqzwxm4zfska1lu.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/5yb3ff2gziejszjraai4ciwq0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/5yjqjmrododoue946j39jmwfc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/5yl5vcg3vjtphnvrsrktsyurz.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/5zpcmanilt8e2i0yek1cuqd7e.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/617njvr91ijbhhc0389e65zfd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/65pcak4ic9ghdchgfvzxukfwm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6chla0dfaqu6ko1ed4g5k0bh0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6dha81kzsqapq01tfd1ffhn5t.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6dwliga6ntx8akhuepotm02li.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6eeyt2onsz4l0rn33i4n314x7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6gdktngd679y245srd3v2u6j1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6hrpu48xpvotylm8qqzlek6pe.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6i2tf5fogqrefbtk2k0fts08v.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6ibg4ld8tp7hsdf7vp08zfkv9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6jvbdq95re5su1n4fg6dvsxny.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6l0x7rweo4qd11s9f7wd1fnkn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6ql1fqbod42inazne8so3r6nq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6tvxdiznwcf9qr6zr7t086222.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/6u4nbzagid291wdm8eld817sn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/73pr4riia6fm36v2mv8ualvka.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/74cgx3dwb9sdxhksblzagmgs9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/76lc81i63omemb4kbapvkca61.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/77ez5zdjkfv1zyb8kv2947iyt.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/77n53sp5bl9wwwv4jl8coksgc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/79208bu4cupe8s8ag77748np0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/7dzxkv9xf71qotmq14g7qipu3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/7enne7dwhbn9ouo4nat6p64y7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/7ifcfokmc84o6qcxxle9rrakh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/7k6mmlbc5ftl68d8ps1umrvop.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/7pmvgu8la26u135mfw40eq3pz.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/7pudfjwumqe0op1k7lbc9s305.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/7qge0m80mpin4pqsga7rx6vd2.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/7qnf98333jk7yfs3fspeve0q3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/7si0wz8kjxdid6s2fbs5ftinw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/7wbimrjjl3fezkh4fn8fqhtbe.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/7zoldj2kvzvtu6ofzkvjt1vzj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/814nro5tv6adkg94g0gissjx0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/851cadq8cj4zqgfig20ibrwlk.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/85vmp0djfzbgzc9sk1b9lqzdf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/86o8ak15n4yxwno3ci8cqr3ew.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/8e4dl0pzxkrepa828oby35lt0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/8ecjfrpqv7uw1w57uleobc8gr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/8gnehf271hrk9kykwm93jc6nb.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/8gnm0fpqfspyxqpyra980atnk.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/8h0jc5w6powgi0c8vn1x4dan8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/8i51mbqp49c9ugk5c7x80cd91.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/8ju8n2tsmlect8qpai3uik6r0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/8kp9g30qj1rpiqzt02hv8tuu8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/8lz3p6ny90b4rlpfrhemq20u3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/8m6yim3wrm4bwpwnpyf2mtjcm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/8oza8lu87mxnvp7ym38s0g7zo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/8qrm1j72swz03z7ynlu6yoirv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/904tkn6xn0ms6a1uh0r8yr7c2.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/91vwuknyr86zvl3l8z3vy746m.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/933muzlqc5yxbghdebkf6avry.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/934fq4mkaerkyuisnny7ioqlw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/95ljs53b24a70l0te2ld4s31r.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/95lo1abl1kxmuzj7ncapojddm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/966mezbhzmqio8wd2b3ajd6tf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/97uj60t48p2f63l0sh5e6d7jc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/98435dxc5aa2rn05pq2ewvsrp.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/98oj9sr8guqc7dpmiywbwkii3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/996qyo133d65sqx9xt231jq6r.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/99dqih93zdsfr60bal5e2p2gi.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9dwlw011jr5607lwvmba9451j.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9eyyyddscolgdirto5lbpczmj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9jg91l3p6vhajyh77dxls6fcq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9nq5bobnq5g7fcb42y8ty6etc.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9ovt5zpsr2epwp0gi3il8iytl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9pnc9z4av5hm4ea25vlz4xqsu.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9rsps4hv0b3oliyfzytnl6pcq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9t90e7028ttpxafof3hkpbw75.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9tw92rn8rt9gc2zqs76pk1om8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9vkg1eytunhbsohqqngxrggo5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9vu9qc8ysylnxdqqdh8a79r9k.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9w23rwzl79tncp0og81fl9mbj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9wlug478tatatqr8mvtveqjx4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/9wpqitn8gn028w639ke7tscvn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/a1777q6ws3xopddbts44rj32s.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/a2k3qlloqq55pfe59cf1dw6rl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/a4gqibcoojzeh7my9rjjlsrsq.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/a4vvbuu4ncozciazbzl1i8zgd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/a6wxbk7zenr2hutjts01e6iov.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/a7cx0yqc6gzx2wnalp130o4zo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/a7v64mm0t5svnom84kyjxpy6b.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/ae4ntfvninsr2feomftjf90vl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/ahwroicqyfe1os20j7ev1sx92.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/anx411kz78bpo2cywd5890o5g.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/ao3bgk0tppun9724prghwlyaw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/aptfmhdofankym4ydqcilebt4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/as2fwt6olfgi6jb4k2amz7ts7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/av5atcjkr4wcy3136kpaozqmd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/avatzw7dqxkzql5nbftropvok.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/avh6oyj7x9fzchqngmufcmfxl.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/avq2q54guy470vpthy8xekyb3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/axxc02oln41z0uowdnn637xeh.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/aymzk97ygih4m8kyminhyxpt6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/b4gj43t9hkoxfowc43a749l9j.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/b5or5vxif2f8blj3ob77tb24z.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/b60sxwxw0o2ylu7jzax5k0rf8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/b98kusc5t91zip4xkkb4xq3h1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/b9gmceyqj8dvxgy4y9rho4f6f.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/be58ksg0xcmvouu8jdu7ku62a.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/bf7fhjb5yslr2f68zebwopmyo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/bih7nsbedfkdq1egfrs2eosec.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/bkp6e0xmkndnrfozeojdiqke7.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/bmg2ckiwaw53e3uwi5ohzhta9.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/bro5iu13s0u262x7c0jbin4kd.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/bs45flxj4pgfw49i4q7o3tndv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/btrv9t0byeqthu6z0breyxfns.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/bzf456z8sywkfhdlyqxxn1pko.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/c0ln86pcd9vjhnujjfz3qxuc6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/c0ww24nyysdf0e136ubn5r02e.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/c2q5ct4feeb9e3vfnuize1nf8.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/c4pskjp43r5bgyamxyozunso6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/cb0rqc2rt9eu5puq2a16hxd2m.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/cdszqc7973d6ctu39nrp1smua.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/cech6pkto4risvcalhoa6fhoj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/cj3pqf8ctvl4w48ziqf8n23x4.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/co6e78io5knk3p1hrqwi845jr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/crj7k979kc8xd5cwcqjll6y8h.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/ct5tx4jcgdi9fm52l8zadz4t1.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/ct7burgpfko9hflp1y63sqfz0.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/czvn67h4gqbefj0zvx6uf8d17.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/d0n01icjvhek62d0qzj34kq8t.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/d13r4esoekm2lkqm7w3p1w0bv.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/d2yh5h5og1qvbsr3lojzd9yk3.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/d31zb3ij77b5hr1mmgcmngbgr.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/d5gbhyb9pph1lpm1jt0on2r6g.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/d6aeidzpukgoerv43g3zqo4sy.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/daag5kuru4m55iiofl7ip7w4i.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/dc6sckox1ypwkn2mppc90ud74.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/dep-graph.bin
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/deqdwclxmpdz5piqvzutwscpg.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/df6f8dr5ct7epsam22we32vw6.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/dh9jqj2rm8e4rshzxuglhedsf.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/dhy5blv8scs0bl7esfxufbkyo.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/dkipqw38umlarkzecanc8qu5y.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/dlp0a6ydnnnped0nhs3rjpt8v.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/drhsr5ravh4ue0q51qhh5a48h.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/dtqnos2cqx4l8kq31x07rub4j.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/dvggmkioolno1uygkxip3vw61.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/dwuftppkcwreuiybdtaet4t6i.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/e75iq1ogwrzlfh0t1gjkr1fwm.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/ebykw8su7ejmpbclf7la10hts.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/efbgk5tw3ki9iu3iyrzqqnalu.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/ejike8sqb267nzu63vfrygj10.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/ekz8zuk9efsff6wfazktchn6f.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/eltrapn5jhodszwme04ztf0gy.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/em7117y9h7qw16aejmuv5fcr5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/en1xdikii9cox4n9l2gib4wfn.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/enjz5e38i8yyuk6hurs6oftsw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/enknqn2imsl5isq96vzz8rtgw.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/epei7l7tnqzlsth330qwxqf1b.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/eqpoa8rcel4hfr8llhvv9o86r.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/es8bkgyag3zgza0q42qtnw5fg.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/eu8gkgxuwar6qc4q47dw5qcs5.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/euki0c9lb7s1g9bs0ewspcc48.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/evvbsd35evv83bvtw1pkewj2p.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/eyyalb83667luca561uj187pj.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/f3sbzjtlib0iiedh5w4jy5zmz.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/f4e91hmjayx6ii7qh2vs8pv5q.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/f4h8cz9aq8r8zfzgxz10fh16p.o
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/query-cache.bin
#	rust-ai/target/debug/incremental/crm_ai-2kj6g3410uujl/s-hjpx9exzve-13aasyu-8m42mwmdcyk7dmjy2u6p8pip3/work-products.bin
2026-06-22 21:52:37 +02:00
Hannah_Bagga d6e4908c18 intial setup is completed 2026-06-22 21:45:14 +02:00
caitlin ba947cea87 I fixed the lightmode on the sidebar and added
more colour themes. I fixed the settings buttons.
2026-06-22 16:26:45 +02:00
caitlin 95e87c8429 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-22 15:41:25 +02:00
caitlin c5766d1624 Fixed notifications button 2026-06-22 15:39:55 +02:00
Ace 6c88dcca7b changing directory for the AI search 2026-06-22 13:35:06 +02:00
Hannah_Bagga a517b91de1 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-22 13:10:01 +02:00
Hannah_Bagga 61c0a9e83f anything 2026-06-22 13:06:19 +02:00
Ace 0bae5bb746 pakage update 2026-06-22 13:03:39 +02:00
Ace be5808f3fc AI somewhat added 2026-06-22 12:37:38 +02:00
Ace 571af27f50 added update from code pull new changes now 2026-06-19 10:19:57 +02:00
William_Britz e5726fdbb2 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-19 09:53:52 +02:00
William_Britz ebc29c47a4 ANYTHING 2026-06-19 09:52:24 +02:00
Ace 7255bae281 Update README.md
Added what needed to be installed for project to work
2026-06-18 23:11:22 +02:00
Ace 8e27a25ff1 Update README.md
updated just what had to be additionally installed
2026-06-18 23:09:57 +02:00
Ace 6ffbc7c9b4 uh added some stuff forgot got drunk 2026-06-18 23:04:20 +02:00
Ace adbcc4b9af feat: add system monitor component and API for performance metrics
feat: implement conversations API with message retrieval and posting

feat: add avatar URL handling for users and update user role definitions

feat: create chat system tables in the database with initial seed data

fix: update user roles from "sales_user" to "sales" for consistency

chore: add middleware for system API route access

fixed fuckups on john's side, added a benchmark

Made Graphs actualy load from database and realtime data, graphs will update and percentages will be mathematically made,  and made realtime

added chatcart edits, made graphs glow.

added in some little small home feeling fuctions

added in a slide show, in login with qoutes from creators because i want to leave a print on it saying we made this shit, we built it brick for brick

login page added qoutes some random, and made them shuffle from left to right one dissapears other come in

adding shape to the textbox for the qoutes

Fixed my chat fuckups when it comes to chats
2026-06-18 22:48:03 +02:00
caitlin f5d09298a2 fixed css 2026-06-18 16:57:59 +02:00
caitlin 02ef2b9f1f I Fixed the notifications 2026-06-18 16:52:30 +02:00
caitlin 6fe41b6c96 I added functionality to the notification button 2026-06-18 16:47:31 +02:00
caitlin 1e4b8df8dd I added functioonality to the notifications 2026-06-18 16:43:26 +02:00
William_Britz e7e4d371c2 Updated Login 2026-06-18 16:12:10 +02:00
William_Britz b19a00c6c2 Updated the Login 2026-06-18 16:10:02 +02:00
1003 changed files with 449281 additions and 1366 deletions
+1
View File
@@ -0,0 +1 @@
rust-ai/target/** linguist-generated=true -diff -merge
+58
View File
@@ -0,0 +1,58 @@
name: Build & Auto-Repair
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Install Playwright
run: npx playwright install chromium firefox
- name: Build check
id: build
continue-on-error: true
run: npx tsc --noEmit
- name: Run self-healing setup
if: steps.build.outcome == 'failure'
run: node scripts/setup.mjs --self-heal
- name: Run code repair agent
if: steps.build.outcome == 'failure'
env:
OLLAMA_HOST: ${{ vars.OLLAMA_HOST || 'http://localhost:11434' }}
run: node scripts/code-repair-agent.mjs --ci
- name: Re-check build after repair
id: rebuild
continue-on-error: true
run: npx tsc --noEmit
- name: Commit fixes
if: steps.build.outcome == 'failure' && steps.rebuild.outcome == 'success'
run: |
git config user.name "CRM Repair Bot"
git config user.email "bot@coastit.co.za"
git add -A
git diff --cached --quiet || git commit -m "[bot] Auto-repair build errors"
git push
- name: Build failed — report
if: steps.rebuild.outcome == 'failure'
run: |
echo "::error::Build still failing after auto-repair. Manual intervention required."
exit 1
+24
View File
@@ -34,9 +34,33 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# runtime data
data/
data/ai/
data/ai/jobs.jsonl
# logs
*.log
*.out
error-log
# vercel
.vercel
# python
browser-use-service/venv/
browser-use-service/__pycache__/
browser-use-service/.env
# typescript
*.tsbuildinfo
next-env.d.ts
.git
.git_bak
node_modules
target
# rust build artifacts
rust-ai/target/
# browser-use-service generated files
browser-use-service/*.txt
+42
View File
@@ -0,0 +1,42 @@
{
"version": 2,
"description": "Template registry for self-healing setup. Maps internal import paths to templates.",
"templates": {
"data/stickers": {
"template": "stickers.ts",
"description": "Emoji-based sticker packs for media picker"
},
"data/constants": {
"template": "constants.ts",
"description": "App-wide constants (lead statuses, roles, event types)"
},
"lib/utils": {
"template": "utils.ts",
"description": "Utility functions (cn, formatters, helpers)"
},
"types/index": {
"template": "types-index.ts",
"description": "Core type definitions (User, Lead, DashboardStats)"
},
"hooks/use-media": {
"template": "hooks/use-media.ts",
"description": "Media query hook"
},
"hooks/use-debounce": {
"template": "hooks/use-debounce.ts",
"description": "Debounce hook"
},
"hooks/use-local-storage": {
"template": "hooks/use-local-storage.ts",
"description": "LocalStorage hook with SSR safety"
}
},
"fallbackTemplates": {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts"
}
}
@@ -0,0 +1,7 @@
// ── Generic Component Template ────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/components/* imports. Customize as needed.
export default function GenericComponent() {
return <div />;
}
+54
View File
@@ -0,0 +1,54 @@
// ── App Constants ────────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/constants.ts to customize.
export const APP_NAME = "CoastIT CRM";
export const APP_VERSION = "1.0.0";
export const LEAD_STATUSES = {
new: { label: "New", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-yellow-500" },
qualified: { label: "Qualified", color: "bg-purple-500" },
proposal: { label: "Proposal", color: "bg-orange-500" },
won: { label: "Won", color: "bg-green-500" },
lost: { label: "Lost", color: "bg-red-500" },
} as const;
export const USER_ROLES = {
super_admin: { label: "Super Admin", level: 1 },
admin: { label: "Admin", level: 2 },
sales: { label: "Sales", level: 3 },
dev: { label: "Developer", level: 4 },
} as const;
export const EVENT_TYPES = [
"meeting",
"call",
"email",
"task",
"note",
] as const;
export const EVENT_STATUSES = [
"scheduled",
"completed",
"cancelled",
"rescheduled",
] as const;
export const AI_MODELS = {
chat: "llama3.2:3b",
scraper: "dolphin-llama3:8b",
coder: "qwen2.5-coder:1.5b-base",
} as const;
export const PAGINATION = {
defaultLimit: 50,
maxLimit: 200,
} as const;
export const DATE_FORMATS = {
short: "MMM d, yyyy",
long: "MMMM d, yyyy",
time: "h:mm a",
datetime: "MMM d, yyyy h:mm a",
} as const;
@@ -0,0 +1,17 @@
// ── Generic Data Module Template ─────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/data/* imports. Customize as needed.
export interface DataItem {
id: string;
name: string;
[key: string]: unknown;
}
export function getDataItems(): DataItem[] {
return [];
}
export function getDataItem(id: string): DataItem | undefined {
return undefined;
}
@@ -0,0 +1,10 @@
// ── Generic Hook Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/hooks/* imports. Customize as needed.
import { useState, useEffect } from "react";
export function useHook<T>(initialValue: T): [T, (value: T) => void] {
const [value, setValue] = useState(initialValue);
return [value, setValue];
}
@@ -0,0 +1,15 @@
// ── useDebounce Hook ──────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
@@ -0,0 +1,29 @@
// ── useLocalStorage Hook ──────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(initialValue);
useEffect(() => {
try {
const item = window.localStorage.getItem(key);
if (item) setStoredValue(JSON.parse(item));
} catch {
// corrupted data — use initial value
}
}, [key]);
const setValue = (value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch {
// storage full or disabled
}
};
return [storedValue, setValue];
}
@@ -0,0 +1,18 @@
// ── useMedia Hook ─────────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useMedia(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
@@ -0,0 +1,7 @@
// ── Generic Lib Module Template ──────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/lib/* imports. Customize as needed.
export function libFunction(): void {
// Placeholder
}
+95
View File
@@ -0,0 +1,95 @@
// ── Sticker Packs ─────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
export interface Sticker {
id: string;
name: string;
emoji?: string;
svg?: string;
}
export interface StickerPack {
id: string;
name: string;
stickers: Sticker[];
}
const reactionStickers: Sticker[] = [
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
{ id: "heart", name: "Heart", emoji: "❤️" },
{ id: "laugh", name: "Laugh", emoji: "😂" },
{ id: "wow", name: "Wow", emoji: "😮" },
{ id: "sad", name: "Sad", emoji: "😢" },
{ id: "angry", name: "Angry", emoji: "😡" },
{ id: "clap", name: "Clap", emoji: "👏" },
{ id: "fire", name: "Fire", emoji: "🔥" },
{ id: "100", name: "100", emoji: "💯" },
{ id: "eyes", name: "Eyes", emoji: "👀" },
{ id: "pray", name: "Pray", emoji: "🙏" },
];
const animalStickers: Sticker[] = [
{ id: "cat", name: "Cat", emoji: "🐱" },
{ id: "dog", name: "Dog", emoji: "🐶" },
{ id: "panda", name: "Panda", emoji: "🐼" },
{ id: "fox", name: "Fox", emoji: "🦊" },
{ id: "bear", name: "Bear", emoji: "🐻" },
{ id: "koala", name: "Koala", emoji: "🐨" },
{ id: "tiger", name: "Tiger", emoji: "🐯" },
{ id: "lion", name: "Lion", emoji: "🦁" },
{ id: "monkey", name: "Monkey", emoji: "🐵" },
{ id: "frog", name: "Frog", emoji: "🐸" },
{ id: "penguin", name: "Penguin", emoji: "🐧" },
{ id: "bird", name: "Bird", emoji: "🐦" },
];
const foodStickers: Sticker[] = [
{ id: "pizza", name: "Pizza", emoji: "🍕" },
{ id: "burger", name: "Burger", emoji: "🍔" },
{ id: "taco", name: "Taco", emoji: "🌮" },
{ id: "sushi", name: "Sushi", emoji: "🍣" },
{ id: "ramen", name: "Ramen", emoji: "🍜" },
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
{ id: "cake", name: "Cake", emoji: "🍰" },
{ id: "coffee", name: "Coffee", emoji: "☕" },
{ id: "beer", name: "Beer", emoji: "🍺" },
{ id: "wine", name: "Wine", emoji: "🍷" },
{ id: "donut", name: "Donut", emoji: "🍩" },
{ id: "cookie", name: "Cookie", emoji: "🍪" },
];
const activityStickers: Sticker[] = [
{ id: "football", name: "Football", emoji: "⚽" },
{ id: "basketball", name: "Basketball", emoji: "🏀" },
{ id: "tennis", name: "Tennis", emoji: "🎾" },
{ id: "running", name: "Running", emoji: "🏃" },
{ id: "swimming", name: "Swimming", emoji: "🏊" },
{ id: "cycling", name: "Cycling", emoji: "🚴" },
{ id: "gym", name: "Gym", emoji: "🏋️" },
{ id: "yoga", name: "Yoga", emoji: "🧘" },
{ id: "music", name: "Music", emoji: "🎵" },
{ id: "party", name: "Party", emoji: "🎉" },
{ id: "gift", name: "Gift", emoji: "🎁" },
{ id: "trophy", name: "Trophy", emoji: "🏆" },
];
const stickerPacks: StickerPack[] = [
{ id: "reactions", name: "Reactions", stickers: reactionStickers },
{ id: "animals", name: "Animals", stickers: animalStickers },
{ id: "food", name: "Food & Drink", stickers: foodStickers },
{ id: "activities", name: "Activities", stickers: activityStickers },
];
export function getStickerPacks(): StickerPack[] {
return stickerPacks;
}
export function getStickerPack(id: string): StickerPack | undefined {
return stickerPacks.find((p) => p.id === id);
}
export function getSticker(packId: string, stickerId: string): Sticker | undefined {
const pack = getStickerPack(packId);
return pack?.stickers.find((s) => s.id === stickerId);
}
@@ -0,0 +1,10 @@
// ── Generic Types Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/types/* imports. Customize as needed.
export type GenericType = Record<string, unknown>;
export interface GenericInterface {
id: string;
[key: string]: unknown;
}
+141
View File
@@ -0,0 +1,141 @@
// ── Type Definitions ──────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/types-index.ts to customize.
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;
createdAt: string;
}
export interface Lead {
id: string;
companyName: string;
contactName: string;
email: string;
phone: string;
source: string;
description: string;
status: LeadStatus;
assignedUserId: string | null;
assignedUser: User | null;
createdAt: string;
updatedAt: string;
}
export type LeadStatus =
| "new"
| "contacted"
| "qualified"
| "proposal"
| "won"
| "lost";
export interface DashboardStats {
totalLeads: number;
newLeads: number;
contactedLeads: number;
qualifiedLeads: number;
proposalLeads: number;
wonLeads: number;
lostLeads: number;
conversionRate: number;
monthlyBreakdown: {
label: string;
total: number;
new: number;
contacted: number;
qualified: number;
proposal: number;
won: number;
lost: number;
}[];
leadsPerMonth: { label: string; leads: number; won: number }[];
recentLeads: Lead[];
statusDistribution: { name: string; value: number; color: string }[];
periodLabel: string;
trends: Record<string, { pct: number; up: boolean }>;
}
export interface Note {
id: string;
leadId: string;
userId: string;
authorName: string;
authorAvatar: string;
authorRole: UserRole;
note: string;
createdAt: string;
updatedAt: string;
}
export type NotificationType =
| "lead_created"
| "lead_status_changed"
| "lead_assigned"
| "chat_message"
| "note_added"
| "event_scheduled";
export interface Notification {
id: string;
type: NotificationType;
title: string;
description: string;
timestamp: string;
read: boolean;
link?: string;
}
export interface ChatMessage {
id: string;
conversationId: string;
senderId: string;
senderName: string;
senderAvatar: string;
content: string;
timestamp: string;
}
export interface Conversation {
id: string;
participants: { id: string; name: string; avatar: string; role: string }[];
lastMessage: string;
lastMessageTime: string;
unread: number;
messages: ChatMessage[];
}
export type EventType = "meeting" | "call" | "email" | "task" | "note";
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
export interface CalendarEvent {
id: string;
userId: string;
participantId: string | null;
developerId: string | null;
leadId: string | null;
conversationId: string | null;
title: string;
description: string | null;
participantNotes: string | null;
eventType: EventType;
startTime: string;
endTime: string | null;
durationMinutes: number | null;
status: EventStatus;
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
lead: { id: string; companyName: string; contactName: string } | null;
clientName: string | null;
clientEmail: string | null;
clientPhone: string | null;
createdAt: string;
}
@@ -0,0 +1,7 @@
// ── Generic Utils Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/utils/* imports. Customize as needed.
export function utilsFunction(): void {
// Placeholder
}
+76
View File
@@ -0,0 +1,76 @@
// ── Utility Functions ────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/utils.ts to customize.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
...options,
});
}
export function formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length - 3) + "...";
}
export function generateId(): string {
return crypto.randomUUID();
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
export function classNames(...classes: (string | boolean | undefined | null)[]): string {
return classes.filter(Boolean).join(" ");
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function retry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
return fn().catch((err) =>
retries > 0 ? sleep(delay).then(() => retry(fn, retries - 1, delay * 2)) : Promise.reject(err)
);
}
+8
View File
@@ -27,6 +27,14 @@ To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## Install
- Jose
- Node.js
- Add emoji pakages
- Install postgres for databases
- Typescript
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
+35
View File
@@ -0,0 +1,35 @@
/* ============================================================================
Default Theme — Clean, Professional SaaS Design Tokens
This is the standard CRM appearance. No Spidey branding or assets.
============================================================================ */
.theme-default {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 80% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 215 25% 22%;
--input: 215 25% 22%;
--ring: 210 80% 52%;
--radius: 0.5rem;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 210 80% 52%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 215 25% 22%;
--sidebar-ring: 210 80% 52%;
}
+88
View File
@@ -0,0 +1,88 @@
/* ============================================================================
Spidey Theme — Baseline Design Tokens
This is the current website appearance captured as a reusable CSS theme.
============================================================================ */
.theme-spidey {
--background: 240 18% 97%;
--foreground: 240 8% 14%;
--card: 0 0% 100%;
--card-foreground: 240 8% 14%;
--popover: 0 0% 100%;
--popover-foreground: 240 8% 14%;
--secondary: 220 75% 48%;
--secondary-foreground: 0 0% 100%;
--muted: 240 8% 92%;
--muted-foreground: 240 5% 50%;
--accent: 220 15% 90%;
--accent-foreground: 220 60% 28%;
--destructive: 0 70% 46%;
--destructive-foreground: 0 0% 100%;
--border: 240 8% 84%;
--input: 240 8% 84%;
--radius: 0.5rem;
--sidebar: 0 0% 99%;
--sidebar-foreground: 240 8% 14%;
--sidebar-accent: 220 20% 93%;
--sidebar-accent-foreground: 220 70% 28%;
--sidebar-border: 240 8% 84%;
}
.dark.theme-spidey {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--radius: 0.5rem;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
}
.theme-spidey body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
repeating-conic-gradient(
from 10deg,
transparent 0deg 10deg,
rgba(220, 38, 38, 0.04) 10deg 12deg
),
repeating-radial-gradient(
circle at 50% 50%,
transparent 0,
transparent 30px,
rgba(37, 99, 235, 0.03) 30px,
transparent 32px
),
radial-gradient(2px 2px at 10% 20%, rgba(220,38,38,0.35) 0%, transparent 100%),
radial-gradient(2px 2px at 30% 80%, rgba(37,99,235,0.35) 0%, transparent 100%),
radial-gradient(2px 2px at 50% 30%, rgba(220,38,38,0.3) 0%, transparent 100%),
radial-gradient(2px 2px at 70% 60%, rgba(37,99,235,0.3) 0%, transparent 100%),
radial-gradient(2px 2px at 85% 15%, rgba(220,38,38,0.35) 0%, transparent 100%),
radial-gradient(2px 2px at 20% 50%, rgba(37,99,235,0.25) 0%, transparent 100%),
radial-gradient(2px 2px at 65% 85%, rgba(220,38,38,0.25) 0%, transparent 100%),
radial-gradient(2px 2px at 40% 10%, rgba(37,99,235,0.3) 0%, transparent 100%),
radial-gradient(2px 2px at 90% 75%, rgba(220,38,38,0.25) 0%, transparent 100%),
radial-gradient(2px 2px at 5% 60%, rgba(37,99,235,0.3) 0%, transparent 100%),
radial-gradient(2px 2px at 55% 55%, rgba(220,38,38,0.2) 0%, transparent 100%);
background-size: 200% 200%;
animation: drift 60s linear infinite;
}
+52
View File
@@ -0,0 +1,52 @@
{
"theme": {
"id": "spidey",
"name": "Spidey",
"description": "Current website appearance — dark theme with red accents",
"type": "dark",
"default": true
},
"colors": {
"background": "hsl(222.2, 84%, 4.9%)",
"foreground": "hsl(210, 40%, 98%)",
"card": "hsl(222.2, 84%, 4.9%)",
"cardForeground": "hsl(210, 40%, 98%)",
"popover": "hsl(222.2, 84%, 4.9%)",
"popoverForeground": "hsl(210, 40%, 98%)",
"primary": "hsl(0, 100%, 53%)",
"primaryForeground": "hsl(222.2, 47.4%, 11.2%)",
"secondary": "hsl(217.2, 32.6%, 17.5%)",
"secondaryForeground": "hsl(210, 40%, 98%)",
"muted": "hsl(217.2, 32.6%, 17.5%)",
"mutedForeground": "hsl(215, 20.2%, 65.1%)",
"accent": "hsl(217.2, 32.6%, 17.5%)",
"accentForeground": "hsl(210, 40%, 98%)",
"destructive": "hsl(0, 62.8%, 30.6%)",
"destructiveForeground": "hsl(210, 40%, 98%)",
"border": "hsl(217.2, 32.6%, 17.5%)",
"input": "hsl(217.2, 32.6%, 17.5%)",
"ring": "hsl(0, 100%, 53%)",
"sidebar": "hsl(222.2, 84%, 4.9%)",
"sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarPrimary": "hsl(0, 100%, 53%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(217.2, 32.6%, 17.5%)",
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)",
"sidebarRing": "hsl(0, 100%, 53%)"
},
"borderRadius": "0.5rem",
"typography": {
"fontFamily": "Inter, sans-serif",
"headingFont": "Inter, sans-serif"
},
"keyColors": {
"primary": "#FF0000",
"background": "#0a0a0f",
"card": "#141414",
"sidebar": "#0a0a0f",
"border": "#1a1a24",
"text": "#e8e8ef",
"mutedText": "#888888"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT u.id, u.first_name, u.last_name, u.email
FROM users u
WHERE u.deleted_at IS NULL AND u.id != $1
ORDER BY u.first_name ASC`,
[user.id],
)
const users = result.rows.map((r: any) => ({
id: r.id,
name: `${r.first_name} ${r.last_name}`,
email: r.email,
}))
return NextResponse.json({ users })
} catch (error) {
console.error("Event users error:", error)
return NextResponse.json({ error: "Failed to load users" }, { status: 500 })
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { buildIcs } from "@/lib/ics"
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const result = await query(
`SELECT e.id, e.user_id, e.participant_id, e.title, e.description, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
u.email AS user_email, u.first_name AS user_first, u.last_name AS user_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
WHERE e.id = $1 AND e.user_id = $2`,
[id, user.id],
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const r = result.rows[0]
const icsContent = buildIcs({
uid: r.id,
title: r.title,
description: r.description || undefined,
startTime: new Date(r.start_time),
endTime: r.end_time ? new Date(r.end_time) : undefined,
durationMinutes: r.duration_minutes || undefined,
organizerName: `${r.user_first} ${r.user_last}`,
organizerEmail: r.user_email,
attendeeName: r.p_first ? `${r.p_first} ${r.p_last}` : undefined,
attendeeEmail: r.p_email || undefined,
})
return new NextResponse(icsContent, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": `attachment; filename="event-${r.id}.ics"`,
},
})
} catch (error) {
console.error("ICS GET error:", error)
return NextResponse.json({ error: "Failed to generate calendar file" }, { status: 500 })
}
}
+167
View File
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventRescheduled } from "@/lib/email"
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = await request.json()
const existing = await query(
`SELECT e.user_id, e.participant_id, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
u.email AS u_email, u.first_name AS u_first, u.last_name AS u_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
WHERE e.id = $1`,
[id],
user.id,
)
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const old = existing.rows[0]
const isCreator = old.user_id === user.id
const isParticipant = old.participant_id === user.id
if (!isCreator && !isParticipant) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
if (!isCreator && (
(title !== undefined && title !== old.title) ||
(eventType !== undefined && eventType !== old.event_type) ||
(startTime !== undefined && startTime !== old.start_time) ||
(endTime !== undefined && endTime !== old.end_time) ||
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
(participantId !== undefined && participantId !== old.participant_id)
)) {
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
}
const result = await query(
`UPDATE scheduled_events SET
title = COALESCE($1, title),
description = COALESCE($2, description),
participant_notes = COALESCE($3, participant_notes),
event_type = COALESCE($4, event_type),
start_time = COALESCE($5, start_time),
end_time = COALESCE($6, end_time),
duration_minutes = COALESCE($7, duration_minutes),
status = COALESCE($8, status),
participant_id = COALESCE($9, participant_id),
updated_at = NOW()
WHERE id = $10
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
[
title || null,
description ?? null,
participantNotes ?? null,
eventType || null,
startTime || null,
endTime ?? null,
durationMinutes ?? null,
status || null,
participantId ?? null,
id,
],
user.id,
)
const r = result.rows[0]
const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time ||
r.title !== old.title || r.event_type !== old.event_type ||
r.status !== old.status
if (isChanged && r.participant_id && r.participant_id !== user.id) {
sendEventRescheduled({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
participantName: old.p_first ? `${old.p_first} ${old.p_last}` : null,
participantEmail: old.p_email || null,
title: r.title,
description: r.description || undefined,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time || undefined,
durationMinutes: r.duration_minutes || undefined,
}).catch((err) => console.error("Reschedule email error:", err))
}
const creatorResult = await query(
`SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url,
ur.role_id, r.name AS role_name, r.display_name AS role_display
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.id = $1`,
[old.user_id],
)
const creatorInfo = creatorResult.rows[0]
const participantInfo = old.participant_id ? { id: old.participant_id, name: `${old.p_first} ${old.p_last}` || old.participant_id, email: old.p_email || null } : null
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: creatorInfo ? { id: creatorInfo.id, name: `${creatorInfo.first_name} ${creatorInfo.last_name}`, email: creatorInfo.email, role: creatorInfo.role_display || creatorInfo.role_name, avatar: creatorInfo.avatar_url } : { id: old.user_id, name: "Unknown" },
participant: participantInfo,
lead: null,
createdAt: r.created_at,
},
})
} catch (error) {
console.error("Events PATCH error:", error)
return NextResponse.json({ error: "Failed to update event" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const existing = await query(
`SELECT user_id FROM scheduled_events WHERE id = $1`,
[id],
user.id,
)
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
if (existing.rows[0].user_id !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
await query(`DELETE FROM scheduled_events WHERE id = $1`, [id], user.id)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Events DELETE error:", error)
return NextResponse.json({ error: "Failed to delete event" }, { status: 500 })
}
}
+188
View File
@@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventConfirmation } from "@/lib/email"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const start = searchParams.get("start")
const end = searchParams.get("end")
const status = searchParams.get("status")
let sql = `SELECT e.id, e.user_id, e.participant_id, e.lead_id, e.conversation_id,
e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
u.id AS u_id, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.avatar_url AS u_avatar,
ur.role_id AS u_role_id, r.name AS u_role_name, r.display_name AS u_role_display,
p.id AS p_id, p.first_name AS p_first, p.last_name AS p_last, p.email AS p_email, p.avatar_url AS p_avatar,
pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display,
l.id AS l_id, l.company_name, l.contact_name
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
LEFT JOIN leads l ON l.id = e.lead_id
LEFT JOIN user_roles ur ON ur.user_id = e.user_id
LEFT JOIN roles r ON r.id = ur.role_id
LEFT JOIN user_roles pr ON pr.user_id = e.participant_id
LEFT JOIN roles pr2 ON pr2.id = pr.role_id`
const params: unknown[] = []
let idx = 1
if (user.role !== "super_admin") {
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}`
params.push(user.id)
idx++
} else {
sql += ` WHERE 1=1`
}
if (start) {
sql += ` AND e.start_time >= $${idx}`
params.push(start)
idx++
}
if (end) {
sql += ` AND e.start_time <= $${idx}`
params.push(end)
idx++
}
if (status) {
sql += ` AND e.status = $${idx}`
params.push(status)
idx++
}
sql += " ORDER BY e.start_time ASC"
const result = await query(sql, params, user.id)
const events = result.rows.map((r: any) => ({
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: { id: r.u_id, name: `${r.u_first} ${r.u_last}`, email: r.u_email, role: r.u_role_display || r.u_role_name, avatar: r.u_avatar },
participant: r.p_id ? { id: r.p_id, name: `${r.p_first} ${r.p_last}`, email: r.p_email, role: r.p_role_display || r.p_role_name, avatar: r.p_avatar } : null,
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
createdAt: r.created_at,
}))
return NextResponse.json({ events })
} catch (error) {
console.error("Events GET error:", error)
return NextResponse.json({ error: "Failed to load events" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const {
participantId, leadId, conversationId,
title, description, eventType,
startTime, endTime, durationMinutes,
} = await request.json()
if (!title || !startTime) {
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 })
}
const result = await query(
`INSERT INTO scheduled_events (user_id, participant_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
[
user.id,
participantId || null,
leadId || null,
conversationId || null,
title,
description || null,
eventType || "meeting",
startTime,
endTime || null,
durationMinutes || null,
],
user.id,
)
const r = result.rows[0]
if (participantId && participantId !== user.id) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
participantId,
"event_scheduled",
`Meeting scheduled: ${title}`,
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
`/calendar`,
r.id,
"scheduled_event",
],
)
}
const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId],
) : null
const participant = participantResult?.rows[0] || null
sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
participantName: participant ? `${participant.first_name} ${participant.last_name}` : null,
participantEmail: participant?.email || null,
title,
description: description || undefined,
eventType: eventType || "meeting",
startTime,
endTime: endTime || undefined,
durationMinutes: durationMinutes || undefined,
}).catch((err) => console.error("Email error:", err))
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
createdAt: r.created_at,
},
}, { status: 201 })
} catch (error) {
console.error("Events POST error:", error)
return NextResponse.json({ error: "Failed to create event" }, { status: 500 })
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS scheduled_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
participant_id UUID REFERENCES users(id) ON DELETE SET NULL,
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
event_type VARCHAR(20) NOT NULL DEFAULT 'meeting',
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
duration_minutes INT,
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_event_type CHECK (event_type IN ('meeting','call','chat','follow_up','demo','other')),
CONSTRAINT chk_event_status CHECK (status IN ('scheduled','completed','cancelled','rescheduled'))
);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user ON scheduled_events(user_id, start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_participant ON scheduled_events(participant_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_lead ON scheduled_events(lead_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_date ON scheduled_events(start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_status ON scheduled_events(user_id, status);
@@ -0,0 +1,22 @@
-- ============================================================================
-- Migration 013: Row-Level Security for scheduled_events
-- ============================================================================
-- Enables RLS on the scheduled_events table so that users can only see
-- their own events at the database level. This is defense-in-depth:
-- the API already filters by user_id, but RLS ensures data isolation
-- even if there's a bug in the application layer.
--
-- The policy allows all operations when app.current_user_id is NULL
-- (backward-compatible fallback for queries that don't set the variable).
-- When the variable IS set, only rows matching the user's ID are visible.
-- ============================================================================
ALTER TABLE scheduled_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1,19 @@
-- ============================================================================
-- Migration 015: Fix RLS for scheduled_events to include participant_id
-- ============================================================================
-- Previously, the RLS policy only checked user_id, which meant participants
-- could not see events at the database level (the app-layer WHERE clause
-- did the filtering, but RLS was defense-in-depth that missed this case).
--
-- This policy also allows app.current_user_id IS NULL for backward compat
-- with queries that don't set the session variable.
-- ============================================================================
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR participant_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1 @@
ALTER TABLE scheduled_events ADD COLUMN IF NOT EXISTS participant_notes TEXT;
+27
View File
@@ -0,0 +1,27 @@
export type EventType = "call" | "follow_up" | "website_creation"
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
export interface CalendarEvent {
id: string
userId: string
participantId: string | null
developerId: string | null
leadId: string | null
conversationId: string | null
title: string
description: string | null
participantNotes: string | null
eventType: EventType
startTime: string
endTime: string | null
durationMinutes: number | null
status: EventStatus
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
lead: { id: string; companyName: string; contactName: string } | null
clientName: string | null
clientEmail: string | null
clientPhone: string | null
createdAt: string
}
+586
View File
@@ -0,0 +1,586 @@
// ── CRM AI Server ──────────────────────────────────────────────────
// Provides:
// - Chat API (POST /ai/chat) — routes user messages to Ollama for sales coaching
// - Setup wizard endpoints (GET /setup/status, POST /setup/profile, etc.)
// - Combined /status endpoint for splash page health polling
// - Configuration routes (GET/POST /ai/instructions, GET /ai/jobs)
// - Model pull support (POST /setup/ollama/pull)
//
// This is a zero-dependency Node.js HTTP server (no Express needed).
import http from "node:http"
import fs from "node:fs"
import path from "node:path"
import { spawn } from "node:child_process"
import crypto from "node:crypto"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.resolve(__dirname, "..")
// ── Load .env.local ──────────────────────────────────────────────
// Reads key=value pairs and sets them as process.env so they're
// available throughout the server. Ignores comments and blank lines.
// Values with matching quotes are unquoted.
try {
const envPath = path.join(ROOT, ".env.local")
const envContent = fs.readFileSync(envPath, "utf-8")
for (const line of envContent.split("\n")) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) continue
const eqIdx = trimmed.indexOf("=")
if (eqIdx === -1) continue
const k = trimmed.substring(0, eqIdx).trim()
let v = trimmed.substring(eqIdx + 1).trim()
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
if (!process.env[k]) process.env[k] = v
}
console.log("Loaded .env.local")
} catch {
// .env.local may not exist (first run), which is fine
}
// ── Config from env ─────────────────────────────────────────────
const PORT = parseInt(process.env.AI_PORT || "3001", 10)
const HOST = process.env.AI_HOST || "0.0.0.0"
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
const MODEL = process.env.AI_MODEL || "llama3.2:3b"
const SCRAPER_URL = process.env.SCRAPER_URL || "http://127.0.0.1:3008"
const FRONTEND_URL = process.env.FRONTEND_URL || "http://127.0.0.1:3006"
const DATABASE_URL = process.env.DATABASE_URL
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
// ── Setup state ──────────────────────────────────────────────────
// Tracks the Ollama model pull process so the setup wizard can
// poll for download progress.
let pullProcess = null
let pullProgress = { status: "idle", progress: 0, message: "" }
// ── Job loading ─────────────────────────────────────────────────
// Loads job categories from a JSONL file (one JSON object per line).
// Used as context for the AI sales coach chat responses.
function loadJobs() {
try {
const content = fs.readFileSync(JOBS_PATH, "utf-8")
const jobs = content
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
try {
return JSON.parse(l)
} catch {
return null
}
})
.filter(Boolean)
console.log(`Loaded ${jobs.length} job categories`)
return jobs
} catch (e) {
console.error(`Failed to load jobs from ${JOBS_PATH}:`, e.message)
return []
}
}
// ── ai.md management ────────────────────────────────────────────
// ai.md is a Markdown file containing system instructions for the AI.
// It can be read, written, or appended to via the API.
function readInstructions() {
try {
return fs.readFileSync(AI_MD_PATH, "utf-8")
} catch {
return ""
}
}
function writeInstructions(content) {
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
return content
}
function appendToImprovementLog(entry) {
// Adds a timestamped entry to the ## Improvement Log section of ai.md
const current = readInstructions()
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
const logEntry = `\n- ${timestamp}${entry}`
const logSection = "\n## Improvement Log"
const logIndex = current.indexOf(logSection)
if (logIndex !== -1) {
const afterLog = current.substring(logIndex + logSection.length)
const nextSectionIndex = afterLog.search(/\n## /)
if (nextSectionIndex !== -1) {
const insertAt = logIndex + logSection.length + nextSectionIndex
const updated = current.substring(0, insertAt) + logEntry + "\n" + current.substring(insertAt)
writeInstructions(updated)
return updated
}
writeInstructions(current + logEntry + "\n")
return current + logEntry + "\n"
}
const updated = current + `\n${logSection}\n${logEntry}\n`
writeInstructions(updated)
return updated
}
// ── Chat handler ────────────────────────────────────────────────
// scrapeFacebook() calls the scraper service (port 3008) to get leads.
// handleChat() processes user messages — triggers lead scraping when
// the user asks for "leads" or "listings", otherwise routes to Ollama
// for AI-powered sales coaching.
async function scrapeFacebook() {
const profilePath = process.env.FX_PROFILE || ""
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
try {
const body = await new Promise((resolve, reject) => {
const parsed = new URL(SCRAPER_URL)
let done = false
const req = http.request({ hostname: parsed.hostname, port: parsed.port || 3008, path: urlPath, method: "POST", timeout: 60000 }, (res) => {
let data = ""
res.on("data", (c) => data += c)
res.on("end", () => { done = true; resolve(data) })
res.on("error", (e) => { if (!done) { done = true; reject(e) } })
})
req.on("timeout", () => { if (!done) { done = true; req.destroy(); reject(new Error("scraper timeout")) } })
req.on("error", (e) => { if (!done) { done = true; reject(e) } })
req.end()
})
const data = JSON.parse(body)
return data
} catch (e) {
console.error("scrapeFacebook error:", e.message)
return null
}
}
function formatLeads(leads) {
if (!leads || leads.length === 0) return "No leads found from the latest scrape."
let output = `**${leads.length} leads found:**\n\n`
for (let i = 0; i < leads.length; i++) {
const l = leads[i]
output += `${i + 1}. ${l.title || "No title"}\n`
if (l.author) output += ` Author: ${l.author}\n`
if (l.date) output += ` Date: ${l.date}\n`
if (l.url) output += ` URL: ${l.url}\n`
output += "\n"
}
return output.trim()
}
async function handleChat(userMessage, userId, userRole) {
// If the user asks for leads, trigger the scraper
const lowerMsg = userMessage.toLowerCase()
const triggerWords = ["lists", "listings", "leads", "recent leads", "pull leads", "show me leads", "show listings"]
if (triggerWords.some(w => lowerMsg.includes(w))) {
const result = await scrapeFacebook()
if (result && result.success) {
return formatLeads(result.leads)
}
return "Scraper returned no results or encountered an error. Try again later."
}
// Otherwise, build a system prompt with job context and send to Ollama
const jobs = loadedJobs
const instructions = readInstructions()
const jobList = jobs
.map((j) => `- ${j.job_title} (${j.industry}): ${j.description}`)
.join("\n")
const systemPrompt = `You are a Sales AI Assistant for Coast IT CRM. Your role is to help salespeople with tips, strategies, and guidance.
Available job categories to target:
${jobList}
Current instructions:
${instructions}
Provide concise, actionable sales advice. When asked about a specific job category, give targeted tips on finding and engaging prospects in that field. Keep responses under 300 words unless asked for detail.`
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(60000),
body: JSON.stringify({
model: MODEL,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
stream: false,
options: { temperature: 0.7, num_predict: 1024 },
}),
})
if (!ollamaRes.ok) {
const text = await ollamaRes.text()
throw new Error(`Ollama error (${ollamaRes.status}): ${text}`)
}
const data = await ollamaRes.json()
const responseText = data.message?.content || ""
// Persist conversation to PostgreSQL (best-effort — table may not exist yet)
try {
if (pgPool && userId) {
await pgPool.query(
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
[crypto.randomUUID(), userId, userRole || "sales", userMessage, responseText]
)
}
} catch {
// table might not exist, ignore
}
return responseText
}
// ── PG pool (lazy init) ────────────────────────────────────────
// PostgreSQL connection pool for storing conversation history.
// Lazy-initialized so the server starts even without a DB.
let pgPool = null
async function initPg() {
if (!DATABASE_URL) return
try {
const { default: pg } = await import("pg")
pgPool = new pg.Pool({ connectionString: DATABASE_URL, max: 5 })
await pgPool.query("SELECT 1")
console.log("Connected to PostgreSQL")
} catch (e) {
console.warn("PostgreSQL unavailable (AI convos won't be saved):", e.message)
}
}
// ── Request router ─────────────────────────────────────────────
const loadedJobs = loadJobs()
function sendJSON(res, status, data) {
res.writeHead(status, { "Content-Type": "application/json" })
res.end(JSON.stringify(data))
}
function parseBody(req) {
return new Promise((resolve, reject) => {
let body = ""
req.on("data", (chunk) => (body += chunk))
req.on("end", () => {
try {
resolve(JSON.parse(body))
} catch {
reject(new Error("Invalid JSON"))
}
})
req.on("error", reject)
})
}
function parseURL(req) {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
return { pathname: url.pathname, searchParams: url.searchParams }
}
// ── HTTP Server ─────────────────────────────────────────────────
const server = http.createServer(async (req, res) => {
// CORS headers — allow the Next.js frontend (port 3006) to call us
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
if (req.method === "OPTIONS") {
res.writeHead(204)
res.end()
return
}
const { pathname } = parseURL(req)
try {
// GET /splash — loading screen
if (req.method === "GET" && pathname === "/splash") {
const splashPath = path.join(ROOT, "splash.html")
if (fs.existsSync(splashPath)) {
const html = fs.readFileSync(splashPath, "utf-8")
res.writeHead(200, { "Content-Type": "text/html" })
res.end(html)
return
}
sendJSON(res, 200, { status: "ok" })
return
}
// GET /health
if (req.method === "GET" && pathname === "/health") {
return sendJSON(res, 200, { status: "ok", model: MODEL })
}
// GET /status — combined health of all services
// Used by the splash page to check if AI, Scraper, and Frontend are ready.
// Polls each service internally to avoid cross-origin CORS issues.
if (req.method === "GET" && pathname === "/status") {
const { default: http } = await import("http")
const results = { ai: true }
// Check scraper
try {
await new Promise((resolve, reject) => {
const r = http.get(`${SCRAPER_URL}/health`, { timeout: 3000 }, (res) => { res.resume(); resolve() })
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
r.on("error", reject)
})
results.scraper = true
} catch { results.scraper = false }
// Check frontend
try {
await new Promise((resolve, reject) => {
const r = http.get(FRONTEND_URL, { timeout: 3000 }, (res) => { res.resume(); resolve() })
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
r.on("error", reject)
})
results.frontend = true
} catch { results.frontend = false }
return sendJSON(res, 200, results)
}
// ── Setup endpoints ─────────────────────────────────────────
// GET /setup/status — check environment
// Called by the splash page on boot. Returns info about:
// - Ollama availability
// - Model presence
// - Detected browsers with login status
// - Whether this is a first run (wizard needed)
if (req.method === "GET" && pathname === "/setup/status") {
const envExists = fs.existsSync(path.join(ROOT, ".env.local"))
// Ollama check
let ollamaRunning = false
try {
await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) })
ollamaRunning = true
} catch {}
// Model check
let modelAvailable = false
if (ollamaRunning) {
try {
const r = await fetch(`${OLLAMA_URL}/api/show`, {
method: "POST", body: JSON.stringify({ name: MODEL }),
signal: AbortSignal.timeout(5000),
})
modelAvailable = r.ok
} catch {}
}
// Detect all browsers via scraper
let browsers = { firefox: { path: null }, opera: { path: null }, chrome: { path: null }, edge: { path: null } }
let facebookLoggedIn = false
let selectedBrowser = process.env.SELECTED_BROWSER || ""
try {
await fetch(`${SCRAPER_URL}/health`, { signal: AbortSignal.timeout(2000) })
const profiles = await (await fetch(`${SCRAPER_URL}/setup/profile`, { signal: AbortSignal.timeout(5000) })).json()
for (const [b, p] of Object.entries(profiles)) {
if (p) browsers[b] = { path: p }
}
// Check login for the selected browser first, then try all
const detectedList = Object.entries(browsers).filter(([, v]) => v.path)
for (const [b, v] of detectedList) {
try {
const r = await fetch(`${SCRAPER_URL}/setup/check-login`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ browser: b, profile_path: v.path }),
signal: AbortSignal.timeout(20000),
})
if (r.ok) {
const d = await r.json()
browsers[b].logged_in = d.logged_in === true
if (d.logged_in && !facebookLoggedIn) {
facebookLoggedIn = true
if (!selectedBrowser) selectedBrowser = b
}
}
} catch {}
}
} catch {}
const anyDetected = Object.values(browsers).some(v => v.path)
// first_run = any setup step is incomplete
const firstRun = !envExists || !ollamaRunning || !anyDetected || !facebookLoggedIn || !modelAvailable
return sendJSON(res, 200, {
first_run: firstRun,
env_exists: envExists,
ollama_running: ollamaRunning,
model_available: modelAvailable,
model_name: MODEL,
selected_browser: selectedBrowser,
browsers,
facebook_logged_in: facebookLoggedIn,
})
}
// POST /setup/profile — save selected browser + path to .env.local
// Called by the setup wizard when the user confirms their browser choice.
// Writes SELECTED_BROWSER and the matching profile env var to .env.local.
if (req.method === "POST" && pathname === "/setup/profile") {
const body = await parseBody(req)
const browserName = (body.browser || "").trim().toLowerCase()
const profilePath = (body.path || "").trim()
if (!browserName || !["firefox", "opera", "chrome", "edge"].includes(browserName))
return sendJSON(res, 400, { error: "Valid browser required (firefox/opera/chrome/edge)" })
if (!profilePath)
return sendJSON(res, 400, { error: "Path required" })
const envKey = browserName === "firefox" ? "FX_PROFILE"
: browserName === "opera" ? "OPERA_PROFILE"
: browserName === "edge" ? "EDGE_PROFILE"
: "CHROME_PROFILE"
const envPath = path.join(ROOT, ".env.local")
let content = ""
try { content = fs.readFileSync(envPath, "utf-8") } catch {}
let lines = content.split("\n")
// Update or add SELECTED_BROWSER
let foundSel = false
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith("SELECTED_BROWSER=")) {
lines[i] = `SELECTED_BROWSER=${browserName}`
foundSel = true
break
}
}
if (!foundSel) lines.push(`SELECTED_BROWSER=${browserName}`)
// Update or add browser profile
let foundProf = false
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith(`${envKey}=`)) {
lines[i] = `${envKey}=${profilePath}`
foundProf = true
break
}
}
if (!foundProf) lines.push(`${envKey}=${profilePath}`)
fs.writeFileSync(envPath, lines.join("\n"), "utf-8")
process.env.SELECTED_BROWSER = browserName
process.env[envKey] = profilePath
return sendJSON(res, 200, { success: true, browser: browserName, path: profilePath })
}
// POST /setup/check-login — proxy to scraper, accepts browser + profile_path
// The splash page calls this (via the AI server) to verify Facebook login status.
if (req.method === "POST" && pathname === "/setup/check-login") {
const body = await parseBody(req)
const browserName = (body.browser || "").trim().toLowerCase() || process.env.SELECTED_BROWSER || ""
const profilePath = (body.profile_path || "").trim()
if (!profilePath) return sendJSON(res, 200, { logged_in: false, reason: "no_profile" })
try {
const r = await fetch("http://127.0.0.1:3008/setup/check-login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ browser: browserName, profile_path: profilePath }),
signal: AbortSignal.timeout(20000),
})
if (r.ok) { const d = await r.json(); return sendJSON(res, 200, d) }
} catch {}
return sendJSON(res, 200, { logged_in: false, reason: "scraper_unavailable" })
}
// POST /setup/ollama/pull — start pulling the model
// Spawns "ollama pull" as a child process. The setup wizard polls
// the progress endpoint to show a download progress bar.
if (req.method === "POST" && pathname === "/setup/ollama/pull") {
if (pullProcess) return sendJSON(res, 200, { status: "already_running" })
pullProgress = { status: "downloading", progress: 0, message: "Starting..." }
const isWin = process.platform === "win32"
const cmd = isWin ? "ollama.exe" : "ollama"
pullProcess = spawn(cmd, ["pull", MODEL], { stdio: ["ignore", "pipe", "pipe"] })
pullProcess.stdout.on("data", (data) => {
const text = data.toString()
pullProgress.message = text.trim()
// Extract percentage from patterns like "pulling xxxx... 45%"
const m = text.match(/(\d+)%/)
if (m) pullProgress.progress = parseInt(m[1], 10)
})
pullProcess.on("close", (code) => {
pullProcess = null
pullProgress.status = code === 0 ? "done" : "failed"
if (code === 0) pullProgress.progress = 100
})
return sendJSON(res, 200, { status: "started" })
}
// GET /setup/ollama/pull/progress
// Returns current download progress for the setup wizard.
if (req.method === "GET" && pathname === "/setup/ollama/pull/progress") {
return sendJSON(res, 200, pullProgress)
}
// GET /ai/jobs — return loaded job categories
if (req.method === "GET" && pathname === "/ai/jobs") {
return sendJSON(res, 200, { jobs: loadedJobs })
}
// GET /ai/instructions — return current ai.md content
if (req.method === "GET" && pathname === "/ai/instructions") {
const instructions = readInstructions()
return sendJSON(res, 200, { success: true, instructions })
}
// POST /ai/instructions — update ai.md or append improvement log entry
if (req.method === "POST" && pathname === "/ai/instructions") {
const body = await parseBody(req)
if (body.content) {
writeInstructions(body.content)
} else if (body.entry) {
appendToImprovementLog(body.entry)
}
return sendJSON(res, 200, {
success: true,
instructions: readInstructions(),
})
}
// POST /ai/chat — main AI chat endpoint
// Accepts { message, user_id?, user_role? } and returns AI response.
// user_role must be "sales", "admin", or "super_admin" if provided.
if (req.method === "POST" && pathname === "/ai/chat") {
const chunks = []
req.on("data", c => chunks.push(c))
req.on("end", async () => {
try {
const rawBody = Buffer.concat(chunks).toString()
const body = JSON.parse(rawBody)
const { message, user_id, user_role } = body
if (!message) {
return sendJSON(res, 400, { error: "message is required" })
}
const validRoles = ["sales", "admin", "super_admin"]
if (user_role && !validRoles.includes(user_role)) {
return sendJSON(res, 403, { error: "Forbidden" })
}
const response = await handleChat(message, user_id || "", user_role || "sales")
sendJSON(res, 200, { response })
} catch (e) {
if (!res.headersSent) sendJSON(res, 500, { error: e.message })
}
})
return
}
// 404 fallback
sendJSON(res, 404, { error: "Not found" })
} catch (err) {
console.error("Request error:", err)
sendJSON(res, 500, { error: err.message })
}
})
// ── Start ───────────────────────────────────────────────────────
server.listen(PORT, HOST, () => {
console.log(`CRM AI server listening on http://${HOST}:${PORT}`)
console.log(` Model: ${MODEL}`)
console.log(` Ollama: ${OLLAMA_URL}`)
})
initPg()
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
# ── Python Dependencies for the Facebook Scraper (FastAPI) ───────
# Install via: pip install -r requirements.txt
# Web framework for the REST API (health, setup, scrape endpoints)
fastapi>=0.115.0
# ASGI server for running the FastAPI app
uvicorn>=0.34.0
# Browser automation (launches Firefox, Chrome, Edge, Opera via Playwright)
playwright>=1.49.0
# AI-powered browser agent (fallback when direct browser scraping is flagged)
# Uses ChatOllama locally — no API keys needed
browser-use>=0.1.0
# LangChain integration for ChatOllama (provides the LLM for browser-use Agent)
langchain-ollama>=0.2.0
+142
View File
@@ -0,0 +1,142 @@
# CRM AI Sales Assistant — Self-Knowledge
## Identity
You are the CRM AI Sales Assistant for Coast IT CRM.
You run on a Node.js backend (port 3001) and use Ollama with a local model (dolphin3-llama3.2:3b).
Your purpose is to help salespeople close more deals by finding and engaging leads.
## Architecture
```
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
PostgreSQL (conversations)
Python Scraper (:3008) — Facebook scraping via Playwright
```
Three services run concurrently:
- **AI Server** (`ai-server/index.mjs`, port 3001) — chat, setup wizard, config endpoints
- **Frontend** (Next.js, port 3006) — UI for salespeople
- **Scraper** (`browser-use-service/main.py`, port 3008) — Facebook lead discovery
## Capabilities
- Give sales tips and strategies per job category
- Generate cold email and outreach templates
- Handle objections with proven rebuttals
- Analyse prospect behaviour and suggest next steps
- Remember past conversations via PostgreSQL (`ai_conversations` table)
- Run Facebook scraper to find real leads asking for services
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
## Facebook Scraper
The scraper lives at `browser-use-service/main.py` port 3008.
### How It Works
1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
3. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
6. **Stealth mechanics**:
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
- Random decoy page visits (e.g., Facebook Groups) between searches
- Profile directory is temp-copied and cleaned up after each scrape
- Detection signal monitoring (checkpoint, login pages, security challenges)
7. **2-pass classification (dead-accurate)**:
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
### Lead Categories
Two categories, selectable when starting a scrape:
**Website Creation:**
- Target: people explicitly REQUESTING a website built/designed/created for them
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
- Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
**Tutoring:**
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
- Request terms: same as website category — must co-occur with a target keyword
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
### Multi-Language Pipeline (Phase Order)
4 South African languages in structured phases:
- **Phase 1 (English)**: primary query + supplementary English searches
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
### Output Format
Each lead returned includes:
- `title` — post preview text
- `author` — poster's name (may include location in name)
- `content` — extracted post text
- `url` — direct link to the post
- `date` — when posted (filtered within 7 days)
- `category` — "website" or "tutor"
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
### Configuration via Env Vars
- `SELECTED_BROWSER``firefox` (default), `chrome`, `opera`, `edge`, or `auto`
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
## How to Start Scraping
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
2. Open the frontend at `http://localhost:3006`
3. Select a job category (Website Creation or Tutoring)
4. Click "Search Facebook" — the scraper runs and returns leads
5. Leads are saved in the CRM for follow-up
## Sales Tips
- Cold emails should be under 150 words
- Follow up within 48 hours
- Personalise every outreach with the prospect's name and company
- Use open-ended questions in discovery calls
- Always ask for the next step before ending a call
- For website leads: mention specific pages or features they requested
- For tutoring leads: reference the subject and age group they mentioned
## Job Targeting
- Developers respond best to technical value props
- Marketing managers care about ROI and metrics
- C-level executives want brevity and business impact
- Parents hiring tutors: empathy and qualifications matter most
## Response Rules
- Be direct and actionable — no fluff, no AI disclaimers
- Use short paragraphs and bullet points
- Never mention being an AI or language model
- If you don't know something, say so honestly
- Prioritise the user's role: salespeople need speed, admins need control
- When asked about scraping, give specific guidance on categories and languages
## Self-Improvement Protocol
1. You notice a gap in your knowledge or a pattern in user questions
2. You call `POST /ai/instructions` with:
- `entry`: description of the improvement
- `content`: optional full replacement of ai.md
3. The improvement is logged and loaded into the next system prompt
## Improvement Log
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
+2
View File
@@ -0,0 +1,2 @@
{"job_title":"Website Creation","keywords":["need a website","build my website","create a website for me","website for my business","need someone to build","who can build me","looking for a web developer","need a web designer","i need a website","need help with my website","looking for someone to create","need a site for","want a website for","need ecommerce website","need landing page","website for my small business","need my website redesigned","can someone build me","anyone know a web developer","recommend a web developer","need a new website"],"industry":"Technology","description":"Find people asking for websites, landing pages, or online stores to be built"}
{"job_title":"Tutoring","keywords":["need a tutor","looking for a tutor","tutor for my child","need help with homework","private tutor needed","looking for tutoring","need a math tutor","english tutor needed","online tutor for","lessons for my child","help my child with","need someone to tutor","looking for someone to teach","tutoring for my","need help learning","recommend a tutor","anyone know a tutor","need a private tutor","tutoring services for my child","need academic help"],"industry":"Education","description":"Find parents and students asking for tutoring services"}
+4 -4
View File
@@ -126,10 +126,10 @@ separate 1:N child tables.
| Username | Password | Role |
|----------|----------|------|
| `superadmin_demo` | `SuperAdmin@2026` | SUPER_ADMIN |
| `admin_demo` | `AdminAccess@2026` | ADMIN |
| `sales_demo` | `SalesAccess@2026` | SALES_USER |
| `dev_demo` | `DevTesting@2026` | DEVELOPER |
| `superadmin_demo` | `[REDACTED]` | SUPER_ADMIN |
| `admin_demo` | `[REDACTED]` | ADMIN |
| `sales_demo` | `[REDACTED]` | SALES_USER |
| `dev_demo` | `[REDACTED]` | DEVELOPER |
---
+46 -41
View File
@@ -22,7 +22,7 @@ $$ LANGUAGE plpgsql;
-- 1. AUTHENTICATION & ROLE MANAGEMENT
-- ============================================================================
CREATE TABLE roles (
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL,
display_name VARCHAR(100),
@@ -35,7 +35,7 @@ CREATE TABLE roles (
CREATE UNIQUE INDEX uq_roles_name ON roles(name);
CREATE TABLE permissions (
CREATE TABLE IF NOT EXISTS permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
resource VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
@@ -46,7 +46,7 @@ CREATE TABLE permissions (
CREATE UNIQUE INDEX uq_permissions ON permissions(resource, action);
CREATE TABLE role_permissions (
CREATE TABLE IF NOT EXISTS role_permissions (
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
granted_by UUID,
@@ -54,7 +54,7 @@ CREATE TABLE role_permissions (
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE users (
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
@@ -83,7 +83,7 @@ CREATE INDEX idx_users_is_active ON users(is_active) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_is_locked ON users(is_locked) WHERE is_locked = TRUE AND deleted_at IS NULL;
CREATE INDEX idx_users_created_by ON users(created_by);
CREATE TABLE user_roles (
CREATE TABLE IF NOT EXISTS user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_by UUID REFERENCES users(id),
@@ -98,7 +98,7 @@ CREATE INDEX idx_user_roles_role ON user_roles(role_id);
-- 2. BAN & SUSPENSION SYSTEM
-- ============================================================================
CREATE TABLE banned_users (
CREATE TABLE IF NOT EXISTS banned_users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
banned_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
banned_by UUID NOT NULL REFERENCES users(id),
@@ -122,7 +122,7 @@ CREATE INDEX idx_banned_users_active ON banned_users(banned_user_id)
WHERE is_reversed = FALSE;
CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by);
CREATE TABLE suspended_users (
CREATE TABLE IF NOT EXISTS suspended_users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
suspended_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
suspended_by UUID NOT NULL REFERENCES users(id),
@@ -145,7 +145,7 @@ CREATE INDEX idx_suspended_users_suspended_by ON suspended_users(suspended_by);
-- 3. SESSION & LOGIN MANAGEMENT
-- ============================================================================
CREATE TABLE sessions (
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
@@ -161,7 +161,7 @@ CREATE INDEX idx_sessions_user ON sessions(user_id) WHERE is_active = TRUE;
CREATE UNIQUE INDEX uq_sessions_token ON sessions(token_hash);
CREATE INDEX idx_sessions_expires ON sessions(expires_at) WHERE is_active = TRUE;
CREATE TABLE login_attempts (
CREATE TABLE IF NOT EXISTS login_attempts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id),
username_attempted VARCHAR(100) NOT NULL,
@@ -181,7 +181,7 @@ CREATE INDEX idx_login_attempts_time ON login_attempts(attempted_at DESC);
-- 4. CUSTOMER MANAGEMENT
-- ============================================================================
CREATE TABLE customer_statuses (
CREATE TABLE IF NOT EXISTS customer_statuses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
@@ -196,7 +196,7 @@ CREATE TABLE customer_statuses (
CREATE UNIQUE INDEX uq_customer_statuses_name ON customer_statuses(name) WHERE deleted_at IS NULL;
CREATE TABLE customers (
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_type VARCHAR(20) NOT NULL,
status_id UUID NOT NULL REFERENCES customer_statuses(id),
@@ -218,7 +218,7 @@ CREATE INDEX idx_customers_score ON customers(score DESC) WHERE deleted_at IS NU
CREATE INDEX idx_customers_tags ON customers USING GIN(tags) WHERE deleted_at IS NULL;
CREATE INDEX idx_customers_created ON customers(created_at DESC) WHERE deleted_at IS NULL;
CREATE TABLE individual_customers (
CREATE TABLE IF NOT EXISTS individual_customers (
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
@@ -233,7 +233,7 @@ CREATE TABLE individual_customers (
CREATE INDEX idx_individual_names ON individual_customers(last_name, first_name);
CREATE TABLE company_customers (
CREATE TABLE IF NOT EXISTS company_customers (
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
company_name VARCHAR(255) NOT NULL,
registration_number VARCHAR(100),
@@ -250,7 +250,7 @@ CREATE TABLE company_customers (
CREATE INDEX idx_company_name ON company_customers(company_name);
CREATE INDEX idx_company_industry ON company_customers(industry);
CREATE TABLE contact_information (
CREATE TABLE IF NOT EXISTS contact_information (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL,
@@ -270,7 +270,7 @@ CREATE INDEX idx_contacts_type ON contact_information(type) WHERE deleted_at IS
CREATE INDEX idx_contacts_value ON contact_information(value) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_contacts_primary ON contact_information(customer_id, type) WHERE is_primary = TRUE AND deleted_at IS NULL;
CREATE TABLE addresses (
CREATE TABLE IF NOT EXISTS addresses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
address_type VARCHAR(20) NOT NULL,
@@ -294,7 +294,7 @@ CREATE INDEX idx_addresses_country ON addresses(country) WHERE deleted_at IS NUL
CREATE INDEX idx_addresses_city ON addresses(city) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_addresses_primary ON addresses(customer_id, address_type) WHERE is_primary = TRUE AND deleted_at IS NULL;
CREATE TABLE customer_notes (
CREATE TABLE IF NOT EXISTS customer_notes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id),
@@ -315,7 +315,7 @@ CREATE INDEX idx_notes_pinned ON customer_notes(is_pinned) WHERE is_pinned = TRU
-- 5. CUSTOMER & INCIDENT REPORTS
-- ============================================================================
CREATE TABLE customer_reports (
CREATE TABLE IF NOT EXISTS customer_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
reported_by UUID NOT NULL REFERENCES users(id),
@@ -339,7 +339,7 @@ CREATE INDEX idx_customer_reports_reported_by ON customer_reports(reported_by);
CREATE INDEX idx_customer_reports_status ON customer_reports(status);
CREATE INDEX idx_customer_reports_severity ON customer_reports(severity);
CREATE TABLE incident_reports (
CREATE TABLE IF NOT EXISTS incident_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reported_by UUID NOT NULL REFERENCES users(id),
target_user_id UUID REFERENCES users(id),
@@ -368,7 +368,7 @@ CREATE INDEX idx_incident_reports_severity ON incident_reports(severity);
-- 6. LEAD MANAGEMENT
-- ============================================================================
CREATE TABLE lead_sources (
CREATE TABLE IF NOT EXISTS lead_sources (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
@@ -380,7 +380,7 @@ CREATE TABLE lead_sources (
CREATE UNIQUE INDEX uq_lead_sources_name ON lead_sources(name) WHERE deleted_at IS NULL;
CREATE TABLE lead_stages (
CREATE TABLE IF NOT EXISTS lead_stages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
@@ -395,7 +395,7 @@ CREATE TABLE lead_stages (
CREATE UNIQUE INDEX uq_lead_stages_name ON lead_stages(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_lead_stages_sort ON lead_stages(sort_order);
CREATE TABLE leads (
CREATE TABLE IF NOT EXISTS leads (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
source_id UUID REFERENCES lead_sources(id),
stage_id UUID NOT NULL REFERENCES lead_stages(id),
@@ -431,7 +431,7 @@ CREATE INDEX idx_leads_converted ON leads(converted_customer_id) WHERE converted
CREATE INDEX idx_leads_email ON leads(email) WHERE deleted_at IS NULL;
CREATE INDEX idx_leads_active ON leads(stage_id, assigned_to) WHERE deleted_at IS NULL AND converted_customer_id IS NULL;
CREATE TABLE lead_conversions (
CREATE TABLE IF NOT EXISTS lead_conversions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
@@ -447,7 +447,7 @@ CREATE INDEX idx_lead_conversions_customer ON lead_conversions(customer_id);
-- 7. PRODUCT CATALOG
-- ============================================================================
CREATE TABLE product_categories (
CREATE TABLE IF NOT EXISTS product_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
@@ -462,7 +462,7 @@ CREATE TABLE product_categories (
CREATE UNIQUE INDEX uq_product_categories_name ON product_categories(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_product_categories_parent ON product_categories(parent_id);
CREATE TABLE products (
CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
category_id UUID REFERENCES product_categories(id),
name VARCHAR(255) NOT NULL,
@@ -487,7 +487,7 @@ CREATE INDEX idx_products_active ON products(is_active) WHERE deleted_at IS NULL
-- 8. SALES PIPELINE
-- ============================================================================
CREATE TABLE deal_stages (
CREATE TABLE IF NOT EXISTS deal_stages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
description TEXT,
@@ -502,7 +502,7 @@ CREATE TABLE deal_stages (
CREATE UNIQUE INDEX uq_deal_stages_name ON deal_stages(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_deal_stages_sort ON deal_stages(sort_order);
CREATE TABLE opportunities (
CREATE TABLE IF NOT EXISTS opportunities (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
lead_id UUID REFERENCES leads(id),
@@ -530,7 +530,7 @@ CREATE INDEX idx_opportunities_won ON opportunities(is_won) WHERE deleted_at IS
CREATE INDEX idx_opportunities_created ON opportunities(created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_opportunities_owner_stage ON opportunities(owner_id, stage_id) WHERE deleted_at IS NULL AND is_won IS NULL;
CREATE TABLE opportunity_products (
CREATE TABLE IF NOT EXISTS opportunity_products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id),
@@ -549,7 +549,7 @@ CREATE INDEX idx_opp_products_product ON opportunity_products(product_id);
-- 9. COMMUNICATION TRACKING
-- ============================================================================
CREATE TABLE communication_types (
CREATE TABLE IF NOT EXISTS communication_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL,
description TEXT,
@@ -561,7 +561,7 @@ CREATE TABLE communication_types (
CREATE UNIQUE INDEX uq_comm_types_name ON communication_types(name);
CREATE TABLE communications (
CREATE TABLE IF NOT EXISTS communications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id),
@@ -585,7 +585,7 @@ CREATE INDEX idx_communications_type ON communications(type_id) WHERE deleted_at
CREATE INDEX idx_communications_created_by ON communications(created_by) WHERE deleted_at IS NULL;
CREATE INDEX idx_communications_started ON communications(started_at) WHERE deleted_at IS NULL;
CREATE TABLE communication_participants (
CREATE TABLE IF NOT EXISTS communication_participants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
communication_id UUID NOT NULL REFERENCES communications(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id),
@@ -602,7 +602,7 @@ CREATE INDEX idx_comm_participants_user ON communication_participants(user_id);
-- 10. TASKS AND ACTIVITIES
-- ============================================================================
CREATE TABLE task_priorities (
CREATE TABLE IF NOT EXISTS task_priorities (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL,
color VARCHAR(7),
@@ -613,7 +613,7 @@ CREATE TABLE task_priorities (
CREATE UNIQUE INDEX uq_task_priorities_name ON task_priorities(name);
CREATE TABLE tasks (
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id),
@@ -645,7 +645,7 @@ CREATE INDEX idx_tasks_reminder ON tasks(reminder_at) WHERE reminder_sent = FALS
-- 11. INVOICE SUPPORT
-- ============================================================================
CREATE TABLE invoice_statuses (
CREATE TABLE IF NOT EXISTS invoice_statuses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL,
description TEXT,
@@ -657,7 +657,7 @@ CREATE TABLE invoice_statuses (
CREATE UNIQUE INDEX uq_invoice_statuses_name ON invoice_statuses(name);
CREATE TABLE invoices (
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id),
opportunity_id UUID REFERENCES opportunities(id),
@@ -689,7 +689,7 @@ CREATE INDEX idx_invoices_issued ON invoices(issued_date DESC) WHERE deleted_at
CREATE INDEX idx_invoices_due ON invoices(due_date) WHERE deleted_at IS NULL;
-- Skipped: cannot use NOW() in partial index predicate (not IMMUTABLE)
CREATE TABLE invoice_items (
CREATE TABLE IF NOT EXISTS invoice_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id),
@@ -705,7 +705,7 @@ CREATE TABLE invoice_items (
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
CREATE INDEX idx_invoice_items_product ON invoice_items(product_id);
CREATE TABLE payments (
CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id UUID NOT NULL REFERENCES invoices(id),
amount DECIMAL(15,2) NOT NULL CHECK (amount > 0),
@@ -729,7 +729,7 @@ CREATE INDEX idx_payments_transaction ON payments(transaction_id) WHERE transact
-- 12. AUDIT LOGGING
-- ============================================================================
CREATE TABLE audit_logs (
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
table_name VARCHAR(100) NOT NULL,
record_id UUID NOT NULL,
@@ -755,7 +755,7 @@ CREATE INDEX idx_audit_session ON audit_logs(session_id);
-- 13. DUPLICATE DETECTION
-- ============================================================================
CREATE TABLE customer_duplicates (
CREATE TABLE IF NOT EXISTS customer_duplicates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
duplicate_customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
@@ -810,11 +810,11 @@ BEGIN
)
LOOP
EXECUTE format(
'CREATE TRIGGER trg_%I_updated_at
'DROP TRIGGER IF EXISTS trg_%I_updated_at ON %I; CREATE TRIGGER trg_%I_updated_at
BEFORE UPDATE ON %I
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column()',
t, t
t, t, t, t
);
END LOOP;
END;
@@ -885,6 +885,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_enforce_create_user ON users;
CREATE TRIGGER trg_enforce_create_user
BEFORE INSERT ON users
FOR EACH ROW
@@ -935,6 +936,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_enforce_role_assignment ON user_roles;
CREATE TRIGGER trg_enforce_role_assignment
BEFORE INSERT ON user_roles
FOR EACH ROW
@@ -973,7 +975,7 @@ BEGIN
audit_action,
CASE WHEN audit_action IN ('UPDATE', 'DELETE') THEN old_row ELSE NULL END,
CASE WHEN audit_action IN ('CREATE', 'UPDATE') THEN new_row ELSE NULL END,
NULL
NULLIF(current_setting('app.current_user_id', true), '')
);
RETURN COALESCE(NEW, OLD);
@@ -998,6 +1000,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_ban ON banned_users;
CREATE TRIGGER trg_audit_ban
AFTER INSERT OR UPDATE ON banned_users
FOR EACH ROW
@@ -1021,6 +1024,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_suspension ON suspended_users;
CREATE TRIGGER trg_audit_suspension
AFTER INSERT OR UPDATE ON suspended_users
FOR EACH ROW
@@ -1051,6 +1055,7 @@ BEGIN
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts;
CREATE TRIGGER trg_audit_login
AFTER INSERT ON login_attempts
FOR EACH ROW
+18 -3
View File
@@ -195,6 +195,9 @@ ON CONFLICT DO NOTHING;
-- admin_demo / AdminAccess@2026
-- sales_demo / SalesAccess@2026
-- dev_demo / DevTesting@2026
-- ewan (password change required on first login)
-- caitlin (password change required on first login)
-- dillen (password change required on first login)
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
@@ -208,7 +211,16 @@ INSERT INTO users (id, username, email, password_hash, first_name, last_name, is
'Sales', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
'Dev', 'User', TRUE, TRUE)
'Dev', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000005', 'ewan', 'ewan@coastit.co.za',
'$2b$12$nO.9p.f4oWFhfScxM8MGQuiR9YjU85YTIqcb1kS.kyDBMHdmQ.EyG',
'Ewan', 'Scheepers', TRUE, TRUE),
('00000000-0000-0000-0000-000000000006', 'caitlin', 'caitlin@coastit.co.za',
'$2b$12$I5FHjje4OA5raP3642twT.Wmcl00rA1/wDPiQjRK14yDgybUjmrYG',
'Caitlin', 'Hermanus', TRUE, TRUE),
('00000000-0000-0000-0000-000000000007', 'dillen', 'dillen@coastit.co.za',
'$2b$12$QnvaRzdJEV/Bq8tp5vguM.ad.oeAcV2bjdzndIFdA4Opn5vY2WVaW',
'Dillen', 'van der Merwe', TRUE, TRUE)
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
@@ -217,7 +229,7 @@ WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL;
-- Set created_by for other users (SUPER_ADMIN created them)
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004')
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004','00000000-0000-0000-0000-000000000005','00000000-0000-0000-0000-000000000006','00000000-0000-0000-0000-000000000007')
AND created_by IS NULL;
-- ============================================================================
@@ -233,7 +245,10 @@ ON CONFLICT DO NOTHING;
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000005', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000006', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000007', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
ON CONFLICT DO NOTHING;
-- ============================================================================
+82
View File
@@ -0,0 +1,82 @@
-- ============================================================================
-- Chat System: conversations, participants, and messages
-- ============================================================================
CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS conversation_participants (
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (conversation_id, user_id)
);
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
CREATE INDEX IF NOT EXISTS idx_conversation_participants_user_id ON conversation_participants(user_id);
CREATE INDEX IF NOT EXISTS idx_messages_conversation_created ON messages(conversation_id, created_at DESC);
-- Seed conversations between superadmin and other users
INSERT INTO conversations (id, created_at) VALUES
('c0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 hours'),
('c0000000-0000-0000-0000-000000000002', NOW() - INTERVAL '1 hour'),
('c0000000-0000-0000-0000-000000000003', NOW() - INTERVAL '30 minutes')
ON CONFLICT DO NOTHING;
-- Add participants (superadmin + each sales/dev user)
INSERT INTO conversation_participants (conversation_id, user_id) VALUES
('c0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001'),
('c0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003'),
('c0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001'),
('c0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000004'),
('c0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001'),
('c0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000002')
ON CONFLICT DO NOTHING;
-- Seed some messages
INSERT INTO messages (id, conversation_id, sender_id, content, created_at) VALUES
('00000000-0000-0000-0000-000000000101', 'c0000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000003', 'Hey! Just finalized the TechCorp deal.',
NOW() - INTERVAL '2 hours'),
('00000000-0000-0000-0000-000000000102', 'c0000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000001', 'Great work! What were the final terms?',
NOW() - INTERVAL '105 minutes'),
('00000000-0000-0000-0000-000000000103', 'c0000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000003', '3-year enterprise license, 15% discount. They signed this morning.',
NOW() - INTERVAL '100 minutes'),
('00000000-0000-0000-0000-000000000104', 'c0000000-0000-0000-0000-000000000002',
'00000000-0000-0000-0000-000000000004', 'The API integration tests are passing on staging.',
NOW() - INTERVAL '50 minutes'),
('00000000-0000-0000-0000-000000000105', 'c0000000-0000-0000-0000-000000000002',
'00000000-0000-0000-0000-000000000001', 'Great, when can we deploy to production?',
NOW() - INTERVAL '45 minutes'),
('00000000-0000-0000-0000-000000000106', 'c0000000-0000-0000-0000-000000000002',
'00000000-0000-0000-0000-000000000004', 'Tomorrow morning after final review.',
NOW() - INTERVAL '40 minutes'),
('00000000-0000-0000-0000-000000000107', 'c0000000-0000-0000-0000-000000000003',
'00000000-0000-0000-0000-000000000002', 'New report shows 23% increase in lead conversion this quarter.',
NOW() - INTERVAL '25 minutes'),
('00000000-0000-0000-0000-000000000108', 'c0000000-0000-0000-0000-000000000003',
'00000000-0000-0000-0000-000000000001', 'That is excellent! Let us discuss in the next team meeting.',
NOW() - INTERVAL '20 minutes')
ON CONFLICT DO NOTHING;
-- Update conversation timestamps
UPDATE conversations SET updated_at = NOW() - INTERVAL '20 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000003';
UPDATE conversations SET updated_at = NOW() - INTERVAL '40 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000002';
UPDATE conversations SET updated_at = NOW() - INTERVAL '100 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000001';
+1
View File
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE conversation_participants ADD COLUMN IF NOT EXISTS last_read_at TIMESTAMPTZ;
UPDATE conversation_participants SET last_read_at = NOW() WHERE last_read_at IS NULL;
+87
View File
@@ -0,0 +1,87 @@
-- Test leads - 2026-06-18
INSERT INTO leads (id, stage_id, assigned_to, company_name, contact_name, email, phone, created_at, updated_at) VALUES
('c0011001-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 1', 'Contact 1', 'contact1@test.com', '555-2636', '2025-12-23T22:09:54Z', '2025-12-23T22:09:54Z'),
('c0011001-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 2', 'Contact 2', 'contact2@test.com', '555-5691', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
('c0011001-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 3', 'Contact 3', 'contact3@test.com', '555-6446', '2025-12-24T22:09:54Z', '2025-12-24T22:09:54Z'),
('c0011001-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 4', 'Contact 4', 'contact4@test.com', '555-5969', '2026-01-02T22:09:54Z', '2026-01-02T22:09:54Z'),
('c0011001-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 5', 'Contact 5', 'contact5@test.com', '555-4200', '2026-01-14T22:09:54Z', '2026-01-14T22:09:54Z'),
('c0011001-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 6', 'Contact 6', 'contact6@test.com', '555-8324', '2026-01-03T22:09:54Z', '2026-01-03T22:09:54Z'),
('c0011001-0000-0000-0000-000000000007', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 7', 'Contact 7', 'contact7@test.com', '555-1047', '2025-12-27T22:09:54Z', '2025-12-27T22:09:54Z'),
('c0011001-0000-0000-0000-000000000008', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 8', 'Contact 8', 'contact8@test.com', '555-634', '2026-01-20T22:09:54Z', '2026-01-20T22:09:54Z'),
('c0011001-0000-0000-0000-000000000009', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 9', 'Contact 9', 'contact9@test.com', '555-3245', '2026-01-15T22:09:54Z', '2026-01-15T22:09:54Z'),
('c0011001-0000-0000-0000-000000000010', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 10', 'Contact 10', 'contact10@test.com', '555-3656', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
('c0011001-0000-0000-0000-000000000011', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 11', 'Contact 11', 'contact11@test.com', '555-2993', '2026-01-23T22:09:54Z', '2026-01-23T22:09:54Z'),
('c0011001-0000-0000-0000-000000000012', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 12', 'Contact 12', 'contact12@test.com', '555-1634', '2026-02-24T22:09:54Z', '2026-02-24T22:09:54Z'),
('c0011001-0000-0000-0000-000000000013', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 13', 'Contact 13', 'contact13@test.com', '555-2032', '2026-02-10T22:09:54Z', '2026-02-10T22:09:54Z'),
('c0011001-0000-0000-0000-000000000014', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 14', 'Contact 14', 'contact14@test.com', '555-867', '2026-01-31T22:09:54Z', '2026-01-31T22:09:54Z'),
('c0011001-0000-0000-0000-000000000015', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 15', 'Contact 15', 'contact15@test.com', '555-2136', '2026-02-02T22:09:54Z', '2026-02-02T22:09:54Z'),
('c0011001-0000-0000-0000-000000000016', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 16', 'Contact 16', 'contact16@test.com', '555-89', '2026-01-20T22:09:54Z', '2026-01-20T22:09:54Z'),
('c0011001-0000-0000-0000-000000000017', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 17', 'Contact 17', 'contact17@test.com', '555-4274', '2026-01-25T22:09:54Z', '2026-01-25T22:09:54Z'),
('c0011001-0000-0000-0000-000000000018', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 18', 'Contact 18', 'contact18@test.com', '555-4294', '2026-03-16T22:09:54Z', '2026-03-16T22:09:54Z'),
('c0011001-0000-0000-0000-000000000019', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 19', 'Contact 19', 'contact19@test.com', '555-7998', '2025-12-30T22:09:54Z', '2025-12-30T22:09:54Z'),
('c0011001-0000-0000-0000-000000000020', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 20', 'Contact 20', 'contact20@test.com', '555-8557', '2026-02-13T22:09:54Z', '2026-02-13T22:09:54Z'),
('c0011001-0000-0000-0000-000000000021', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 21', 'Contact 21', 'contact21@test.com', '555-5170', '2026-04-11T22:09:54Z', '2026-04-11T22:09:54Z'),
('c0011001-0000-0000-0000-000000000022', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 22', 'Contact 22', 'contact22@test.com', '555-6250', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
('c0011001-0000-0000-0000-000000000023', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 23', 'Contact 23', 'contact23@test.com', '555-5611', '2026-01-06T22:09:54Z', '2026-01-06T22:09:54Z'),
('c0011001-0000-0000-0000-000000000024', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 24', 'Contact 24', 'contact24@test.com', '555-3467', '2026-04-16T22:09:54Z', '2026-04-16T22:09:54Z'),
('c0011001-0000-0000-0000-000000000025', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 25', 'Contact 25', 'contact25@test.com', '555-7087', '2026-05-07T22:09:54Z', '2026-05-07T22:09:54Z'),
('c0011001-0000-0000-0000-000000000026', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 26', 'Contact 26', 'contact26@test.com', '555-2945', '2025-12-22T22:09:54Z', '2025-12-22T22:09:54Z'),
('c0011001-0000-0000-0000-000000000027', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 27', 'Contact 27', 'contact27@test.com', '555-9502', '2026-01-09T22:09:54Z', '2026-01-09T22:09:54Z'),
('c0011001-0000-0000-0000-000000000028', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 28', 'Contact 28', 'contact28@test.com', '555-8114', '2026-05-10T22:09:54Z', '2026-05-10T22:09:54Z'),
('c0011001-0000-0000-0000-000000000029', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 29', 'Contact 29', 'contact29@test.com', '555-4926', '2026-03-02T22:09:54Z', '2026-03-02T22:09:54Z'),
('c0011001-0000-0000-0000-000000000030', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 30', 'Contact 30', 'contact30@test.com', '555-2421', '2026-03-17T22:09:54Z', '2026-03-17T22:09:54Z'),
('c0011001-0000-0000-0000-000000000031', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 31', 'Contact 31', 'contact31@test.com', '555-7506', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
('c0011001-0000-0000-0000-000000000032', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 32', 'Contact 32', 'contact32@test.com', '555-6832', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
('c0011001-0000-0000-0000-000000000033', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 33', 'Contact 33', 'contact33@test.com', '555-6548', '2026-01-07T22:09:54Z', '2026-01-07T22:09:54Z'),
('c0011001-0000-0000-0000-000000000034', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 34', 'Contact 34', 'contact34@test.com', '555-8793', '2026-01-02T22:09:54Z', '2026-01-02T22:09:54Z'),
('c0011001-0000-0000-0000-000000000035', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 35', 'Contact 35', 'contact35@test.com', '555-6217', '2026-01-29T22:09:54Z', '2026-01-29T22:09:54Z'),
('c0011001-0000-0000-0000-000000000036', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 36', 'Contact 36', 'contact36@test.com', '555-9206', '2025-12-31T22:09:54Z', '2025-12-31T22:09:54Z'),
('c0011001-0000-0000-0000-000000000037', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 37', 'Contact 37', 'contact37@test.com', '555-1599', '2025-12-26T22:09:54Z', '2025-12-26T22:09:54Z'),
('c0011001-0000-0000-0000-000000000038', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 38', 'Contact 38', 'contact38@test.com', '555-3873', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
('c0011001-0000-0000-0000-000000000039', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 39', 'Contact 39', 'contact39@test.com', '555-6122', '2026-01-09T22:09:54Z', '2026-01-09T22:09:54Z'),
('c0011001-0000-0000-0000-000000000040', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 40', 'Contact 40', 'contact40@test.com', '555-832', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
('c0011001-0000-0000-0000-000000000041', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 41', 'Contact 41', 'contact41@test.com', '555-5979', '2026-01-11T22:09:54Z', '2026-01-11T22:09:54Z'),
('c0011001-0000-0000-0000-000000000042', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 42', 'Contact 42', 'contact42@test.com', '555-1823', '2025-12-26T22:09:54Z', '2025-12-26T22:09:54Z'),
('c0011001-0000-0000-0000-000000000043', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 43', 'Contact 43', 'contact43@test.com', '555-7102', '2026-02-28T22:09:54Z', '2026-02-28T22:09:54Z'),
('c0011001-0000-0000-0000-000000000044', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 44', 'Contact 44', 'contact44@test.com', '555-5106', '2026-01-22T22:09:54Z', '2026-01-22T22:09:54Z'),
('c0011001-0000-0000-0000-000000000045', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 45', 'Contact 45', 'contact45@test.com', '555-3278', '2026-03-27T22:09:54Z', '2026-03-27T22:09:54Z'),
('c0011001-0000-0000-0000-000000000046', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 46', 'Contact 46', 'contact46@test.com', '555-4973', '2026-04-28T22:09:54Z', '2026-04-28T22:09:54Z'),
('c0011001-0000-0000-0000-000000000047', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 47', 'Contact 47', 'contact47@test.com', '555-3297', '2026-03-01T22:09:54Z', '2026-03-01T22:09:54Z'),
('c0011001-0000-0000-0000-000000000048', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 48', 'Contact 48', 'contact48@test.com', '555-4584', '2026-03-18T22:09:54Z', '2026-03-18T22:09:54Z'),
('c0011001-0000-0000-0000-000000000049', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 49', 'Contact 49', 'contact49@test.com', '555-6788', '2026-03-04T22:09:54Z', '2026-03-04T22:09:54Z'),
('c0011001-0000-0000-0000-000000000050', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 50', 'Contact 50', 'contact50@test.com', '555-2734', '2026-01-03T22:09:54Z', '2026-01-03T22:09:54Z'),
('c0011001-0000-0000-0000-000000000051', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 51', 'Contact 51', 'contact51@test.com', '555-2625', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
('c0011001-0000-0000-0000-000000000052', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 52', 'Contact 52', 'contact52@test.com', '555-620', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
('c0011001-0000-0000-0000-000000000053', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 53', 'Contact 53', 'contact53@test.com', '555-6691', '2025-12-22T22:09:54Z', '2025-12-22T22:09:54Z'),
('c0011001-0000-0000-0000-000000000054', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 54', 'Contact 54', 'contact54@test.com', '555-8847', '2026-01-25T22:09:54Z', '2026-01-25T22:09:54Z'),
('c0011001-0000-0000-0000-000000000055', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 55', 'Contact 55', 'contact55@test.com', '555-2187', '2026-02-02T22:09:54Z', '2026-02-02T22:09:54Z'),
('c0011001-0000-0000-0000-000000000056', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 56', 'Contact 56', 'contact56@test.com', '555-3267', '2026-01-19T22:09:54Z', '2026-01-19T22:09:54Z'),
('c0011001-0000-0000-0000-000000000057', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 57', 'Contact 57', 'contact57@test.com', '555-8119', '2026-03-05T22:09:54Z', '2026-03-05T22:09:54Z'),
('c0011001-0000-0000-0000-000000000058', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 58', 'Contact 58', 'contact58@test.com', '555-3181', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
('c0011001-0000-0000-0000-000000000059', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 59', 'Contact 59', 'contact59@test.com', '555-4403', '2026-02-23T22:09:54Z', '2026-02-23T22:09:54Z'),
('c0011001-0000-0000-0000-000000000060', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 60', 'Contact 60', 'contact60@test.com', '555-1407', '2026-04-06T22:09:54Z', '2026-04-06T22:09:54Z'),
('c0011001-0000-0000-0000-000000000061', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 61', 'Contact 61', 'contact61@test.com', '555-2333', '2026-02-27T22:09:54Z', '2026-02-27T22:09:54Z'),
('c0011001-0000-0000-0000-000000000062', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 62', 'Contact 62', 'contact62@test.com', '555-1436', '2026-04-26T22:09:54Z', '2026-04-26T22:09:54Z'),
('c0011001-0000-0000-0000-000000000063', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 63', 'Contact 63', 'contact63@test.com', '555-7135', '2026-04-19T22:09:54Z', '2026-04-19T22:09:54Z'),
('c0011001-0000-0000-0000-000000000064', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 64', 'Contact 64', 'contact64@test.com', '555-9366', '2026-01-22T22:09:54Z', '2026-01-22T22:09:54Z'),
('c0011001-0000-0000-0000-000000000065', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 65', 'Contact 65', 'contact65@test.com', '555-7135', '2026-02-18T22:09:54Z', '2026-02-18T22:09:54Z'),
('c0011001-0000-0000-0000-000000000066', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 66', 'Contact 66', 'contact66@test.com', '555-6854', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
('c0011001-0000-0000-0000-000000000067', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 67', 'Contact 67', 'contact67@test.com', '555-2322', '2025-12-24T22:09:54Z', '2025-12-24T22:09:54Z'),
('c0011001-0000-0000-0000-000000000068', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 68', 'Contact 68', 'contact68@test.com', '555-2632', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
('c0011001-0000-0000-0000-000000000069', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 69', 'Contact 69', 'contact69@test.com', '555-876', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
('c0011001-0000-0000-0000-000000000070', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 70', 'Contact 70', 'contact70@test.com', '555-974', '2026-01-15T22:09:54Z', '2026-01-15T22:09:54Z'),
('c0011001-0000-0000-0000-000000000071', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 71', 'Contact 71', 'contact71@test.com', '555-1919', '2026-01-18T22:09:54Z', '2026-01-18T22:09:54Z'),
('c0011001-0000-0000-0000-000000000072', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 72', 'Contact 72', 'contact72@test.com', '555-3034', '2025-12-25T22:09:54Z', '2025-12-25T22:09:54Z'),
('c0011001-0000-0000-0000-000000000073', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 73', 'Contact 73', 'contact73@test.com', '555-1795', '2026-01-27T22:09:54Z', '2026-01-27T22:09:54Z'),
('c0011001-0000-0000-0000-000000000074', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 74', 'Contact 74', 'contact74@test.com', '555-727', '2026-01-08T22:09:54Z', '2026-01-08T22:09:54Z'),
('c0011001-0000-0000-0000-000000000075', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 75', 'Contact 75', 'contact75@test.com', '555-3877', '2026-03-08T22:09:54Z', '2026-03-08T22:09:54Z'),
('c0011001-0000-0000-0000-000000000076', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 76', 'Contact 76', 'contact76@test.com', '555-6384', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
('c0011001-0000-0000-0000-000000000077', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 77', 'Contact 77', 'contact77@test.com', '555-5607', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
('c0011001-0000-0000-0000-000000000078', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 78', 'Contact 78', 'contact78@test.com', '555-6225', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
('c0011001-0000-0000-0000-000000000079', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 79', 'Contact 79', 'contact79@test.com', '555-9288', '2026-04-16T22:09:54Z', '2026-04-16T22:09:54Z'),
('c0011001-0000-0000-0000-000000000080', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 80', 'Contact 80', 'contact80@test.com', '555-8660', '2026-02-16T22:09:54Z', '2026-02-16T22:09:54Z'),
('c0011001-0000-0000-0000-000000000081', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 81', 'Contact 81', 'contact81@test.com', '555-826', '2025-12-28T22:09:54Z', '2025-12-28T22:09:54Z'),
('c0011001-0000-0000-0000-000000000082', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 82', 'Contact 82', 'contact82@test.com', '555-1761', '2026-03-11T22:09:54Z', '2026-03-11T22:09:54Z'),
('c0011001-0000-0000-0000-000000000083', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 83', 'Contact 83', 'contact83@test.com', '555-9860', '2026-05-18T22:09:54Z', '2026-05-18T22:09:54Z'),
('c0011001-0000-0000-0000-000000000084', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 84', 'Contact 84', 'contact84@test.com', '555-1104', '2026-06-01T22:09:54Z', '2026-06-01T22:09:54Z'),
('c0011001-0000-0000-0000-000000000085', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 85', 'Contact 85', 'contact85@test.com', '555-9528', '2026-06-14T22:09:54Z', '2026-06-14T22:09:54Z');
+13
View File
@@ -0,0 +1,13 @@
-- AI Sales Assistant tables
CREATE TABLE IF NOT EXISTS ai_conversations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'sales',
message TEXT NOT NULL,
response TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user ON ai_conversations(user_id);
CREATE INDEX IF NOT EXISTS idx_ai_conversations_created ON ai_conversations(created_at DESC);
+23
View File
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
link TEXT,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, created_at DESC) WHERE is_read = FALSE;
CREATE TABLE IF NOT EXISTS notification_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
lead_assigned BOOLEAN NOT NULL DEFAULT TRUE,
lead_status BOOLEAN NOT NULL DEFAULT TRUE,
note_added BOOLEAN NOT NULL DEFAULT FALSE,
daily_digest BOOLEAN NOT NULL DEFAULT FALSE,
weekly_report BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS company_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_name VARCHAR(255) NOT NULL DEFAULT '',
company_email VARCHAR(255) NOT NULL DEFAULT '',
company_phone VARCHAR(50) NOT NULL DEFAULT '',
company_website VARCHAR(255) NOT NULL DEFAULT '',
company_address TEXT NOT NULL DEFAULT '',
updated_by UUID REFERENCES users(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO company_settings (company_name, company_email, company_phone, company_website, company_address)
VALUES ('Coastal IT Solutions', 'info@coastalit.com', '(555) 123-4567', 'https://coastalit.com', '123 Business Ave, Suite 100, San Francisco, CA 94105')
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS user_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
timezone VARCHAR(100) NOT NULL DEFAULT 'america-los_angeles',
date_format VARCHAR(10) NOT NULL DEFAULT 'mdy',
items_per_page INTEGER NOT NULL DEFAULT 20,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,4 @@
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_id UUID;
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id) WHERE context_type IS NOT NULL;
@@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS facebook_accounts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
label VARCHAR(100) NOT NULL,
profile_path TEXT NOT NULL,
cookie_file TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_scrape_at TIMESTAMPTZ,
last_success_at TIMESTAMPTZ,
last_error_at TIMESTAMPTZ,
last_error_message TEXT,
consecutive_failures INT NOT NULL DEFAULT 0,
flagged BOOLEAN NOT NULL DEFAULT FALSE,
flagged_at TIMESTAMPTZ,
flagged_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fb_accounts_active ON facebook_accounts(is_active);
CREATE INDEX IF NOT EXISTS idx_fb_accounts_flagged ON facebook_accounts(flagged);
CREATE TABLE IF NOT EXISTS facebook_scrape_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
account_id UUID NOT NULL REFERENCES facebook_accounts(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
success BOOLEAN NOT NULL DEFAULT FALSE,
leads_found INT NOT NULL DEFAULT 0,
error_message TEXT,
detected_flag VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account ON facebook_scrape_logs(account_id, created_at DESC);
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS scheduled_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
participant_id UUID REFERENCES users(id) ON DELETE SET NULL,
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
event_type VARCHAR(20) NOT NULL DEFAULT 'meeting',
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
duration_minutes INT,
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_event_type CHECK (event_type IN ('meeting','call','chat','follow_up','demo','other')),
CONSTRAINT chk_event_status CHECK (status IN ('scheduled','completed','cancelled','rescheduled'))
);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user ON scheduled_events(user_id, start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_participant ON scheduled_events(participant_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_lead ON scheduled_events(lead_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_date ON scheduled_events(start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_status ON scheduled_events(user_id, status);
+22
View File
@@ -0,0 +1,22 @@
create table if not exists contacts (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade,
name text not null,
phone text not null,
avatar_url text,
created_at timestamp default now()
);
alter table contacts enable row level security;
create policy "Users see own contacts"
on contacts for select
using (auth.uid() = user_id);
create policy "Users insert own contacts"
on contacts for insert
with check (auth.uid() = user_id);
create policy "Users delete own contacts"
on contacts for delete
using (auth.uid() = user_id);
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
token VARCHAR(64) NOT NULL UNIQUE,
phone VARCHAR(50),
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days'
);
CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS sent_emails (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recipient VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
body_html TEXT,
body_text TEXT,
event_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sent_emails_recipient ON sent_emails(recipient);
CREATE INDEX IF NOT EXISTS idx_sent_emails_created ON sent_emails(created_at DESC);
+22
View File
@@ -0,0 +1,22 @@
-- ============================================================================
-- Migration 013: Row-Level Security for scheduled_events
-- ============================================================================
-- Enables RLS on the scheduled_events table so that users can only see
-- their own events at the database level. This is defense-in-depth:
-- the API already filters by user_id, but RLS ensures data isolation
-- even if there's a bug in the application layer.
--
-- The policy allows all operations when app.current_user_id is NULL
-- (backward-compatible fallback for queries that don't set the variable).
-- When the variable IS set, only rows matching the user's ID are visible.
-- ============================================================================
ALTER TABLE scheduled_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1,609 @@
-- ============================================================================
-- CRM Security Architecture Upgrade
-- Internal Company CRM — Prioritizes control, recovery, and maintainability
-- ============================================================================
-- Implements:
-- • Dual password storage (bcrypt + pgcrypto AES reversible)
-- • SUPER_ADMIN master key recovery system
-- • Row Level Security (RLS) on CRM tables
-- • Database export logging
-- • Backup logging
-- • Immutable admin action tracking
-- • SQL injection defense (parameterized queries enforced app-side)
-- ============================================================================
-- ============================================================================
-- 1. DUAL PASSWORD STORAGE
-- ============================================================================
-- password_hash (bcrypt) — used for normal login authentication
-- password_encrypted (AES) — reversible encryption for emergency credential recovery
-- ============================================================================
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_encrypted TEXT;
-- ============================================================================
-- 2. SUPER_ADMIN MASTER KEY SYSTEM
-- ============================================================================
-- Stores the master decryption key used with pgp_sym_encrypt/pgp_sym_decrypt.
-- Only accessible by SUPER_ADMIN role via security definer functions.
-- ============================================================================
CREATE TABLE IF NOT EXISTS master_keys (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
key_name VARCHAR(100) NOT NULL UNIQUE,
key_value TEXT NOT NULL,
description TEXT,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX idx_master_keys_active ON master_keys(key_name) WHERE is_active = TRUE;
-- ============================================================================
-- 3. DATABASE EXPORT LOGGING
-- ============================================================================
-- Tracks all database exports and SQL dumps performed by SUPER_ADMIN.
-- Immutable — never allow deletion or update.
-- ============================================================================
CREATE TABLE IF NOT EXISTS database_export_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
exported_by UUID NOT NULL REFERENCES users(id),
export_type VARCHAR(50) NOT NULL,
file_name VARCHAR(500) NOT NULL,
file_size_bytes BIGINT,
record_count INT,
ip_address INET,
user_agent TEXT,
notes TEXT,
exported_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_export_logs_user ON database_export_logs(exported_by);
CREATE INDEX idx_export_logs_time ON database_export_logs(exported_at DESC);
COMMENT ON TABLE database_export_logs IS 'Immutable audit of all database exports. Never DELETE or UPDATE rows.';
COMMENT ON COLUMN database_export_logs.export_type IS 'pg_dump, csv_export, full_backup, selective_export';
-- ============================================================================
-- 4. BACKUP LOGGING
-- ============================================================================
-- Records every automated or manual database backup.
-- Retention: minimum 30 days of backup history.
-- ============================================================================
CREATE TABLE IF NOT EXISTS backup_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
backup_type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
file_name VARCHAR(500),
file_size_bytes BIGINT,
pg_dump_exit_code INT,
error_message TEXT,
checksum VARCHAR(64),
verification_status VARCHAR(20),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
duration_seconds INT,
retention_days INT NOT NULL DEFAULT 30,
triggered_by UUID REFERENCES users(id),
notes TEXT
);
CREATE INDEX idx_backup_logs_time ON backup_logs(started_at DESC);
CREATE INDEX idx_backup_logs_status ON backup_logs(status);
CREATE INDEX idx_backup_logs_type ON backup_logs(backup_type);
CREATE INDEX idx_backup_logs_completed ON backup_logs(status)
WHERE status = 'completed';
-- ============================================================================
-- 5. AUDIT TRIGGER: Password changes
-- ============================================================================
CREATE OR REPLACE FUNCTION audit_password_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN
INSERT INTO audit_logs (
table_name, record_id, action, old_data, new_data, changed_by, ip_address
) VALUES (
'users',
NEW.id,
'UPDATE',
jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required),
jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required),
NULLIF(current_setting('app.current_user_id', true), ''),
NULLIF(current_setting('app.current_ip', true), '')
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_password_change ON users;
CREATE TRIGGER trg_audit_password_change
AFTER UPDATE OF password_hash ON users
FOR EACH ROW
EXECUTE FUNCTION audit_password_change();
-- ============================================================================
-- 6. AUDIT TRIGGER: User creation
-- ============================================================================
CREATE OR REPLACE FUNCTION audit_user_creation()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_logs (
table_name, record_id, action, new_data, changed_by
) VALUES (
'users',
NEW.id,
'CREATE',
jsonb_build_object(
'username', NEW.username,
'email', NEW.email,
'first_name', NEW.first_name,
'last_name', NEW.last_name,
'is_active', NEW.is_active
),
NEW.created_by
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_user_create ON users;
CREATE TRIGGER trg_audit_user_create
AFTER INSERT ON users
FOR EACH ROW
EXECUTE FUNCTION audit_user_creation();
-- ============================================================================
-- 7. AUDIT TRIGGER: Database exports
-- ============================================================================
CREATE OR REPLACE FUNCTION audit_database_export()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_logs (
table_name, record_id, action, new_data, changed_by
) VALUES (
'database_export_logs',
NEW.id,
'CREATE',
jsonb_build_object(
'export_type', NEW.export_type,
'file_name', NEW.file_name,
'file_size_bytes', NEW.file_size_bytes,
'record_count', NEW.record_count
),
NEW.exported_by
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_database_export ON database_export_logs;
CREATE TRIGGER trg_audit_database_export
AFTER INSERT ON database_export_logs
FOR EACH ROW
EXECUTE FUNCTION audit_database_export();
-- ============================================================================
-- 8. AUDIT TRIGGER: Login/logout already exists via login_attempts trigger
-- (trg_audit_login in 001_schema.sql)
-- This enhances it to also track session-level events.
-- ============================================================================
DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts;
CREATE TRIGGER trg_audit_login
AFTER INSERT ON login_attempts
FOR EACH ROW
EXECUTE FUNCTION audit_login();
-- ============================================================================
-- 9. ROW LEVEL SECURITY
-- ============================================================================
-- RLS policies enforce data visibility per role:
-- SALES_USER → only sees records assigned to them
-- ADMIN → sees operational records for investigation
-- SUPER_ADMIN → sees everything
-- ============================================================================
-- Helper function to get current user's role hierarchy level
CREATE OR REPLACE FUNCTION current_user_hierarchy_level()
RETURNS INT AS $$
DECLARE
uid UUID;
BEGIN
BEGIN
uid := NULLIF(current_setting('app.current_user_id', true), '')::UUID;
EXCEPTION WHEN OTHERS THEN
RETURN NULL;
END;
IF uid IS NULL THEN
RETURN NULL;
END IF;
RETURN (
SELECT MIN(r.hierarchy_level)
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = uid
);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- Helper function to get current user's ID from session setting
CREATE OR REPLACE FUNCTION current_user_id()
RETURNS UUID AS $$
BEGIN
BEGIN
RETURN NULLIF(current_setting('app.current_user_id', true), '')::UUID;
EXCEPTION WHEN OTHERS THEN
RETURN NULL;
END;
END;
$$ LANGUAGE plpgsql STABLE;
-- SALES_USER level = 3, ADMIN level = 2, SUPER_ADMIN level = 1
-- ── customers ──
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS rls_customers_select ON customers;
CREATE POLICY rls_customers_select ON customers
FOR SELECT
USING (
current_user_hierarchy_level() IS NULL
OR current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN see all
OR owner_id = current_user_id()
);
DROP POLICY IF EXISTS rls_customers_insert ON customers;
CREATE POLICY rls_customers_insert ON customers
FOR INSERT
WITH CHECK (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN can assign any owner
OR owner_id = current_user_id() -- SALES_USER can only assign to self
)
);
DROP POLICY IF EXISTS rls_customers_update ON customers;
CREATE POLICY rls_customers_update ON customers
FOR UPDATE
USING (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR owner_id = current_user_id()
)
)
WITH CHECK (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR (
owner_id = current_user_id()
AND owner_id = current_user_id() -- SALES_USER cannot reassign ownership
)
)
);
DROP POLICY IF EXISTS rls_customers_delete ON customers;
CREATE POLICY rls_customers_delete ON customers
FOR DELETE
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
-- ── leads ──
ALTER TABLE leads ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS rls_leads_select ON leads;
CREATE POLICY rls_leads_select ON leads
FOR SELECT
USING (
current_user_hierarchy_level() IS NULL
OR current_user_hierarchy_level() <= 2
OR assigned_to = current_user_id()
);
DROP POLICY IF EXISTS rls_leads_insert ON leads;
CREATE POLICY rls_leads_insert ON leads
FOR INSERT
WITH CHECK (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR assigned_to = current_user_id()
)
);
DROP POLICY IF EXISTS rls_leads_update ON leads;
CREATE POLICY rls_leads_update ON leads
FOR UPDATE
USING (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR assigned_to = current_user_id()
)
);
DROP POLICY IF EXISTS rls_leads_delete ON leads;
CREATE POLICY rls_leads_delete ON leads
FOR DELETE
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
-- ── opportunities ──
ALTER TABLE opportunities ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS rls_opportunities_select ON opportunities;
CREATE POLICY rls_opportunities_select ON opportunities
FOR SELECT
USING (
current_user_hierarchy_level() IS NULL
OR current_user_hierarchy_level() <= 2
OR owner_id = current_user_id()
);
DROP POLICY IF EXISTS rls_opportunities_insert ON opportunities;
CREATE POLICY rls_opportunities_insert ON opportunities
FOR INSERT
WITH CHECK (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR owner_id = current_user_id()
)
);
DROP POLICY IF EXISTS rls_opportunities_update ON opportunities;
CREATE POLICY rls_opportunities_update ON opportunities
FOR UPDATE
USING (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR owner_id = current_user_id()
)
);
DROP POLICY IF EXISTS rls_opportunities_delete ON opportunities;
CREATE POLICY rls_opportunities_delete ON opportunities
FOR DELETE
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
-- ── communications ──
ALTER TABLE communications ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS rls_communications_select ON communications;
CREATE POLICY rls_communications_select ON communications
FOR SELECT
USING (
current_user_hierarchy_level() IS NULL
OR current_user_hierarchy_level() <= 2
OR created_by = current_user_id()
);
DROP POLICY IF EXISTS rls_communications_insert ON communications;
CREATE POLICY rls_communications_insert ON communications
FOR INSERT
WITH CHECK (
current_user_hierarchy_level() IS NOT NULL
);
DROP POLICY IF EXISTS rls_communications_delete ON communications;
CREATE POLICY rls_communications_delete ON communications
FOR DELETE
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
-- ── tasks ──
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS rls_tasks_select ON tasks;
CREATE POLICY rls_tasks_select ON tasks
FOR SELECT
USING (
current_user_hierarchy_level() IS NULL
OR current_user_hierarchy_level() <= 2
OR assigned_to = current_user_id()
OR assigned_by = current_user_id()
);
DROP POLICY IF EXISTS rls_tasks_update ON tasks;
CREATE POLICY rls_tasks_update ON tasks
FOR UPDATE
USING (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR assigned_to = current_user_id()
OR assigned_by = current_user_id()
)
);
-- ============================================================================
-- 10. IMMUTABLE AUDIT LOG PROTECTION
-- ============================================================================
-- Prevent deletion or update of audit_logs at the database level.
-- Even SUPER_ADMIN cannot delete historical audit records.
-- ============================================================================
CREATE OR REPLACE FUNCTION prevent_audit_mutation()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'IMMUTABLE: audit_logs cannot be modified or deleted. All changes are permanently recorded.';
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_prevent_audit_delete ON audit_logs;
CREATE TRIGGER trg_prevent_audit_delete
BEFORE DELETE ON audit_logs
FOR EACH ROW
EXECUTE FUNCTION prevent_audit_mutation();
DROP TRIGGER IF EXISTS trg_prevent_audit_update ON audit_logs;
CREATE TRIGGER trg_prevent_audit_update
BEFORE UPDATE ON audit_logs
FOR EACH ROW
EXECUTE FUNCTION prevent_audit_mutation();
-- Same protection for database_export_logs
DROP TRIGGER IF EXISTS trg_prevent_export_delete ON database_export_logs;
CREATE TRIGGER trg_prevent_export_delete
BEFORE DELETE ON database_export_logs
FOR EACH ROW
EXECUTE FUNCTION prevent_audit_mutation();
DROP TRIGGER IF EXISTS trg_prevent_export_update ON database_export_logs;
CREATE TRIGGER trg_prevent_export_update
BEFORE UPDATE ON database_export_logs
FOR EACH ROW
EXECUTE FUNCTION prevent_audit_mutation();
-- ============================================================================
-- 11. ENCRYPTION FUNCTIONS
-- ============================================================================
-- Uses pgcrypto's pgp_sym_encrypt / pgp_sym_decrypt with the master key.
-- Master key is stored in master_keys table, fetched by security definer functions.
-- ============================================================================
-- Get the active master decryption key
-- Only callable by SUPER_ADMIN
CREATE OR REPLACE FUNCTION get_master_decryption_key()
RETURNS TEXT AS $$
DECLARE
key_value TEXT;
uid UUID;
user_level INT;
BEGIN
uid := current_user_id();
IF uid IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
user_level := current_user_hierarchy_level();
IF user_level IS NULL OR user_level > 1 THEN
RAISE EXCEPTION 'ACCESS DENIED: Only SUPER_ADMIN can access the master decryption key';
END IF;
SELECT mk.key_value INTO key_value
FROM master_keys mk
WHERE mk.is_active = TRUE
ORDER BY mk.created_at DESC
LIMIT 1;
RETURN key_value;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Encrypt a plaintext password using the active master key
CREATE OR REPLACE FUNCTION encrypt_password(p_plaintext TEXT)
RETURNS TEXT AS $$
DECLARE
master_key TEXT;
BEGIN
SELECT key_value INTO master_key
FROM master_keys
WHERE is_active = TRUE
ORDER BY created_at DESC
LIMIT 1;
IF master_key IS NULL THEN
RAISE EXCEPTION 'No active master key found. Contact SUPER_ADMIN.';
END IF;
RETURN encode(
pgp_sym_encrypt(p_plaintext, master_key, 'compress-algo=2, cipher-algo=aes256'),
'escape'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Decrypt a password that was encrypted with encrypt_password()
CREATE OR REPLACE FUNCTION decrypt_password(p_encrypted TEXT)
RETURNS TEXT AS $$
DECLARE
master_key TEXT;
BEGIN
master_key := get_master_decryption_key();
RETURN pgp_sym_decrypt(decode(p_encrypted, 'escape'), master_key);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- 12. RLS BYPASS FOR SUPER_ADMIN
-- ============================================================================
-- SUPER_ADMIN bypasses all RLS. This function is used by triggers/policies
-- to check if the current user should bypass restrictions.
-- ============================================================================
CREATE OR REPLACE FUNCTION is_super_admin()
RETURNS BOOLEAN AS $$
BEGIN
RETURN COALESCE(current_user_hierarchy_level(), 999) <= 1;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- ============================================================================
-- 13. SEED DATA: Master key (for development/testing)
-- ============================================================================
-- In production, this key should be set via a secure deployment process.
-- The key is stored in the database and encrypted at rest by PostgreSQL.
-- ============================================================================
INSERT INTO master_keys (key_name, key_value, description, created_by)
SELECT
'MASTER_DECRYPTION_KEY',
encode(gen_random_bytes(32), 'hex'),
'Master key for reversible password encryption. Used with pgp_sym_encrypt/decrypt.',
'00000000-0000-0000-0000-000000000001'
WHERE NOT EXISTS (
SELECT 1 FROM master_keys WHERE key_name = 'MASTER_DECRYPTION_KEY'
);
-- ============================================================================
-- 14. UPDATE SEED DATA: Encrypt passwords for existing test accounts
-- ============================================================================
UPDATE users SET password_encrypted = encrypt_password('SuperAdmin@2026')
WHERE id = '00000000-0000-0000-0000-000000000001' AND password_encrypted IS NULL;
UPDATE users SET password_encrypted = encrypt_password('AdminAccess@2026')
WHERE id = '00000000-0000-0000-0000-000000000002' AND password_encrypted IS NULL;
UPDATE users SET password_encrypted = encrypt_password('SalesAccess@2026')
WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL;
UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026')
WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL;
-- NOTE: New admin users (ewan, caitlin, dillen) have password_encrypted=NULL.
-- They will set their own passwords on first login (password_change_required=TRUE).
-- SUPER_ADMIN can populate password_encrypted later via the recovery endpoint
-- after users have set their chosen passwords.
-- ============================================================================
-- FUTURE REQUIREMENT NOTE:
-- If this system is ever exposed publicly, remove reversible password storage
-- immediately. Keep bcrypt only. Delete the password_encrypted column and
-- master_keys table. The MASTER_DECRYPTION_KEY must never be exposed externally.
-- ============================================================================
+145
View File
@@ -0,0 +1,145 @@
-- ============================================================================
-- Bug Reporting System
-- ============================================================================
-- Access rules:
-- ALL authenticated users can INSERT (submit bug reports)
-- Only ADMIN and SUPER_ADMIN can SELECT, UPDATE (view/manage)
-- SALES_USER and DEVELOPER cannot view bug_reports after submission
-- ============================================================================
CREATE TABLE IF NOT EXISTS bug_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reported_by UUID NOT NULL REFERENCES users(id),
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
severity VARCHAR(20) NOT NULL DEFAULT 'medium'
CHECK (severity IN ('low', 'medium', 'high', 'critical')),
page_url TEXT,
screenshot_url TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'in_progress', 'resolved', 'closed')),
assigned_to UUID REFERENCES users(id),
resolution_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bug_reports_reported_by ON bug_reports(reported_by);
CREATE INDEX IF NOT EXISTS idx_bug_reports_status ON bug_reports(status);
CREATE INDEX IF NOT EXISTS idx_bug_reports_severity ON bug_reports(severity);
CREATE INDEX IF NOT EXISTS idx_bug_reports_assigned ON bug_reports(assigned_to);
CREATE INDEX IF NOT EXISTS idx_bug_reports_created ON bug_reports(created_at DESC);
-- RLS: Allow INSERT for all authenticated users
-- Allow SELECT/UPDATE only for ADMIN and SUPER_ADMIN
ALTER TABLE bug_reports ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS bug_reports_insert ON bug_reports;
CREATE POLICY bug_reports_insert ON bug_reports
FOR INSERT
WITH CHECK (
current_user_id() IS NOT NULL
AND reported_by = current_user_id()
);
DROP POLICY IF EXISTS bug_reports_select ON bug_reports;
CREATE POLICY bug_reports_select ON bug_reports
FOR SELECT
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
DROP POLICY IF EXISTS bug_reports_update ON bug_reports;
CREATE POLICY bug_reports_update ON bug_reports
FOR UPDATE
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
-- Audit trigger for bug report actions
CREATE OR REPLACE FUNCTION audit_bug_report_action()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
VALUES (
'bug_reports',
NEW.id,
'BUG_CREATED',
jsonb_build_object(
'title', NEW.title,
'severity', NEW.severity,
'page_url', NEW.page_url,
'status', NEW.status
),
NEW.reported_by
);
ELSIF TG_OP = 'UPDATE' THEN
IF OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
VALUES (
'bug_reports',
NEW.id,
CASE NEW.status
WHEN 'resolved' THEN 'BUG_RESOLVED'
ELSE 'BUG_UPDATED'
END,
jsonb_build_object(
'old_status', OLD.status,
'new_status', NEW.status,
'assigned_to', NEW.assigned_to,
'resolution_notes', NEW.resolution_notes
),
NULLIF(current_setting('app.current_user_id', true), '')
);
END IF;
IF OLD.assigned_to IS DISTINCT FROM NEW.assigned_to AND NEW.assigned_to IS NOT NULL THEN
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
VALUES (
'bug_reports',
NEW.id,
'BUG_ASSIGNED',
jsonb_build_object(
'assigned_to', NEW.assigned_to,
'previous_assignee', OLD.assigned_to,
'status', NEW.status
),
NULLIF(current_setting('app.current_user_id', true), '')
);
END IF;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_bug_report ON bug_reports;
CREATE TRIGGER trg_audit_bug_report
AFTER INSERT OR UPDATE ON bug_reports
FOR EACH ROW
EXECUTE FUNCTION audit_bug_report_action();
-- Widen audit action constraint to include bug report events
ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS chk_audit_action;
ALTER TABLE audit_logs ADD CONSTRAINT chk_audit_action
CHECK (action::text = ANY (ARRAY[
'CREATE', 'UPDATE', 'DELETE',
'BUG_CREATED', 'BUG_UPDATED', 'BUG_ASSIGNED', 'BUG_RESOLVED',
'LOGIN', 'LOGOUT'
]::text[]));
-- Prevent SALES_USER and DEVELOPER from reading bug_reports directly
-- via RLS bypass attempts (additional safety via trigger)
CREATE OR REPLACE FUNCTION prevent_bug_report_read_bypass()
RETURNS TRIGGER AS $$
DECLARE
user_level INT;
BEGIN
user_level := current_user_hierarchy_level();
IF user_level IS NULL OR user_level > 2 THEN
RAISE EXCEPTION 'ACCESS DENIED: Only ADMIN and SUPER_ADMIN can view bug reports.';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
@@ -0,0 +1,5 @@
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS context_id UUID,
ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id);
@@ -0,0 +1,19 @@
-- ============================================================================
-- Migration 015: Fix RLS for scheduled_events to include participant_id
-- ============================================================================
-- Previously, the RLS policy only checked user_id, which meant participants
-- could not see events at the database level (the app-layer WHERE clause
-- did the filtering, but RLS was defense-in-depth that missed this case).
--
-- This policy also allows app.current_user_id IS NULL for backward compat
-- with queries that don't set the session variable.
-- ============================================================================
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR participant_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1 @@
ALTER TABLE scheduled_events ADD COLUMN IF NOT EXISTS participant_notes TEXT;
@@ -0,0 +1,4 @@
ALTER TABLE scheduled_events
ADD COLUMN developer_id UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX idx_scheduled_events_developer ON scheduled_events(developer_id);
@@ -0,0 +1,8 @@
ALTER TABLE scheduled_events
DROP CONSTRAINT IF EXISTS chk_event_type,
ADD CONSTRAINT chk_event_type CHECK (event_type IN ('call', 'follow_up', 'website_creation'));
ALTER TABLE scheduled_events
ADD COLUMN IF NOT EXISTS client_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_phone VARCHAR(50);
@@ -0,0 +1,2 @@
ALTER TABLE scheduled_events
ALTER COLUMN start_time DROP NOT NULL;
+31
View File
@@ -0,0 +1,31 @@
-- ============================================================================
-- Fixes: password_change_required + audit trigger UUID cast
-- ============================================================================
-- 1. All users use the passwords already set in the seed — no forced change
UPDATE users SET password_change_required = FALSE WHERE password_change_required = TRUE;
-- 2. Fix audit_password_change trigger: current_setting returns TEXT but
-- audit_logs.changed_by is UUID — cast to UUID to prevent type error
CREATE OR REPLACE FUNCTION audit_password_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN
INSERT INTO audit_logs (
table_name, record_id, action, old_data, new_data, changed_by, ip_address
) VALUES (
'users',
NEW.id,
'UPDATE',
jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required),
jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required),
NULLIF(current_setting('app.current_user_id', true), '')::UUID,
NULLIF(current_setting('app.current_ip', true), '')
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 3. Re-enable the trigger (was disabled as workaround for the UUID bug)
ALTER TABLE users ENABLE TRIGGER trg_audit_password_change;
@@ -0,0 +1,69 @@
-- ============================================================================
-- Performance & Missing Indexes
-- ============================================================================
-- scheduled_events: index on created_at for list ordering
CREATE INDEX IF NOT EXISTS idx_scheduled_events_created_at ON scheduled_events(created_at DESC);
-- scheduled_events: composite index for common user+status queries
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user_status ON scheduled_events(user_id, status);
-- messages: index on sender_id for delete-by-sender queries
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_id);
-- messages: composite for conversation listing
CREATE INDEX IF NOT EXISTS idx_messages_conversation_sender ON messages(conversation_id, sender_id);
-- notifications: composite unread badge query
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read, created_at DESC);
-- ai_conversations: composite user+created query
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user_created ON ai_conversations(user_id, created_at DESC);
-- login_attempts: TTL cleanup
CREATE INDEX IF NOT EXISTS idx_login_attempts_cleanup ON login_attempts(attempted_at) WHERE was_successful = false;
-- facebook_scrape_logs: composite account+created index (replaces separate one)
DROP INDEX IF EXISTS idx_fb_scrape_logs_account;
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account_created ON facebook_scrape_logs(account_id, created_at DESC);
-- lead_conversions: composite lead+customer (replaces two separate indexes)
CREATE INDEX IF NOT EXISTS idx_lead_conversions_lead_customer ON lead_conversions(lead_id, customer_id);
-- ============================================================================
-- Cache current_user_hierarchy_level as session variable for RLS performance
-- ============================================================================
CREATE OR REPLACE FUNCTION set_session_user_context(p_user_id UUID)
RETURNS VOID AS $$
DECLARE
level INT;
BEGIN
SELECT MIN(r.hierarchy_level) INTO level
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = p_user_id;
PERFORM set_config('app.current_user_id', p_user_id::TEXT, true);
PERFORM set_config('app.current_user_hierarchy_level', COALESCE(level::TEXT, ''), true);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Update current_user_hierarchy_level() to use the cached session variable
CREATE OR REPLACE FUNCTION current_user_hierarchy_level()
RETURNS INT AS $$
DECLARE
level_text TEXT;
BEGIN
BEGIN
level_text := NULLIF(current_setting('app.current_user_hierarchy_level', true), '');
IF level_text IS NOT NULL THEN
RETURN level_text::INT;
END IF;
EXCEPTION WHEN OTHERS THEN
NULL;
END;
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;
+73 -6
View File
@@ -4,16 +4,83 @@
-- Usage: psql -U postgres -d crm -f run_all.sql
-- ============================================================================
BEGIN;
\set ON_ERROR_STOP on
\echo '=== Running 001_schema.sql (Tables + Constraints + Functions + Views) ==='
\i 001_schema.sql
\echo '=== Running 002_seed.sql (RBAC + Test Accounts + Reference Data) ==='
\i 002_seed.sql
\echo '=== Running 003_chat.sql (Chat Tables) ==='
\i 003_chat.sql
\echo '=== Running 004_avatar_url.sql (Avatar URL Column) ==='
\i 004_avatar_url.sql
\echo '=== Running 005_last_read_at.sql (Last Read At Column) ==='
\i 005_last_read_at.sql
\echo '=== Running 006_test_leads.sql (Test Lead Data) ==='
\i 006_test_leads.sql
\echo '=== Running 007_ai_features.sql (AI Features) ==='
\i 007_ai_features.sql
\echo '=== Running 008_notifications.sql (Notifications + Preferences) ==='
\i 008_notifications.sql
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
\i 009_settings.sql
\echo '=== Running 010_chat_notifications.sql (Chat Notifications) ==='
\i 010_chat_notifications.sql
\echo '=== Running 010_facebook_accounts.sql (Facebook Accounts) ==='
\i 010_facebook_accounts.sql
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
\i 011_calendar_events.sql
\echo '=== Running 012_invites.sql (Invites) ==='
\i 012_invites.sql
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
\i 012_sent_emails.sql
\echo '=== Running 013_security_upgrade.sql (Security Architecture) ==='
\i 013_security_upgrade.sql
\echo '=== Running 013_rls_calendar.sql (Calendar RLS) ==='
\i 013_rls_calendar.sql
\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ==='
\i 014_bug_reports.sql
\echo '=== Running 014_notifications_context.sql (Notifications Context) ==='
\i 014_notifications_context.sql
\echo '=== Running 015_rls_calendar_participant.sql (Calendar RLS Participant Fix) ==='
\i 015_rls_calendar_participant.sql
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
\i 016_participant_notes.sql
\echo '=== Running 017_calendar_developer.sql (Calendar Developer Assignment) ==='
\i 017_calendar_developer.sql
\echo '=== Running 018_website_creation_type.sql (Website Creation Event Type) ==='
\i 018_website_creation_type.sql
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
\i 019_allow_null_start_time.sql
\echo '=== Running 020_fixes.sql (password_change_required + audit trigger fix) ==='
\i 020_fixes.sql
\echo '=== Running 021_performance_indexes.sql (Performance Indexes + RLS Cache) ==='
\i 021_performance_indexes.sql
\echo '=== Migration Complete ==='
\echo ''
\echo 'Test accounts created:'
\echo ' superadmin_demo / SuperAdmin@2026'
\echo ' admin_demo / AdminAccess@2026'
\echo ' sales_demo / SalesAccess@2026'
\echo ' dev_demo / DevTesting@2026'
COMMIT;
+2 -2
View File
@@ -1,6 +1,6 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import nextVitals from "eslint-config-next/core-web-vitals.js";
import nextTs from "eslint-config-next/typescript.js";
const eslintConfig = defineConfig([
...nextVitals,
+71
View File
@@ -0,0 +1,71 @@
# Scrapers - Configuration & Status
## Facebook Scraper
**File:** `rust-ai/src/main.rs`
**Lines:** ~70-85
Target URL (line 72):
```rust
let url = "https://www.facebook.com/search/top/?q=need%20website%20create";
```
**Status:** Uses direct connection (no proxy) — your home IP. Facebook blocks datacenter IPs. May work from a residential connection.
**Test with curl:**
```
curl.exe -s "https://www.facebook.com/search/top/?q=need%20website%20create" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" --max-time 10 2>$null
```
**Expected log output when blocked:**
```
ERROR crm_ai: Facebook scraper error: error sending request for url (https://www.facebook.com/search/top/?q=need%20website%20create)
```
## Reddit Scraper
**File:** `rust-ai/src/main.rs`
**Lines:** ~90-120
Uses `old.reddit.com` (older design that allows scraping without captchas).
Search queries (currently 6, lines 93-100):
- `r/southafrica` — "need website", "web developer"
- Global — '"need a website"', "website quote"
- `r/forhire` — "website"
- `r/smallbusiness` — "website"
**Status:** Working. Reddit results appear as `INFO LEAD:` entries in the server log.
**Test with curl:**
```
curl.exe -s "https://old.reddit.com/r/southafrica/search?q=need+website&sort=new&restrict_sr=on&t=week" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
```
**Expected log output when working:**
```
INFO crm_ai: LEAD: [Post Title] -> https://old.reddit.com/r/.../...
```
## Background Loop
**File:** `rust-ai/src/main.rs`
**Lines:** ~370-383
Both scrapers run together every 60-180 seconds on a `spawn_blocking` thread.
## What You Need to Do
### Facebook
- Try running from your home PC instead — your residential IP may not be blocked
- If still blocked, you need residential proxies (BrightData, IPRoyal, Oxylabs)
- Configure them in the `PROXIES` array at line 25
### Reddit
- Works out of the box via `old.reddit.com`
- If it stops working, Reddit IP-blocked you — use proxies or switch to Playwright
### To add more search queries
Edit the `searches` array in `run_reddit_scraper()` (line 93).
+25
View File
File diff suppressed because one or more lines are too long
+17 -1
View File
@@ -2,7 +2,7 @@ import type { NextConfig } from "next"
const nextConfig: NextConfig = {
eslint: {
ignoreDuringBuilds: true,
ignoreDuringBuilds: false,
},
images: {
remotePatterns: [
@@ -12,6 +12,22 @@ const nextConfig: NextConfig = {
},
],
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
...(process.env.NODE_ENV === "production"
? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }]
: []),
],
},
]
},
}
export default nextConfig
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"playwright": {
"type": "local",
"command": [
"playwright-mcp.cmd",
"--browser",
"chrome",
"--user-data-dir",
"C:\\Users\\Caitlin\\AppData\\Local\\Google\\Chrome\\User Data\\Default",
],
"enabled": true,
},
},
}
+3447 -51
View File
File diff suppressed because it is too large Load Diff
+37 -9
View File
@@ -3,15 +3,32 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3006",
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:signaling": "node signaling-server.mjs",
"dev:open": "node scripts/open-browser.mjs",
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
"dev:next": "next dev -p 3006",
"dev:precheck": "node scripts/precheck.mjs",
"dev:ollama": "node scripts/ensure-ollama.mjs",
"dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
"setup": "node scripts/setup.mjs",
"migrate": "node scripts/run-migrations.mjs",
"db:migrate": "npm run migrate",
"build": "next build",
"start": "next start",
"lint": "eslint"
"start": "next start -p 3006",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"ci": "npm run lint && npx tsc --noEmit && npm test",
"repair": "echo 'Auto-repair disabled for security. Fix errors manually.'",
"repair:watch": "echo 'Auto-repair disabled for security. Fix errors manually.'",
"repair:ci": "echo 'Auto-repair disabled for security. Fix errors manually.'"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^3.9.1",
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
@@ -26,36 +43,47 @@
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.108.2",
"@tanstack/react-table": "^8.20.6",
"autoprefixer": "^10.4.20",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"dotenv": "^17.4.2",
"framer-motion": "^11.15.0",
"jose": "^6.2.3",
"lucide-react": "^0.468.0",
"next": "15.0.4",
"next-themes": "^0.4.4",
"nodemailer": "^9.0.1",
"pg": "^8.21.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"vaul": "^1.1.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^20",
"@next/swc-win32-x64-msvc": "^15.0.4",
"@types/node": "20.19.43",
"@types/nodemailer": "8.0.1",
"@types/pg": "^8.20.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react": "18.3.31",
"@types/react-dom": "18.3.7",
"concurrently": "^10.0.3",
"devenv": "1.0.1",
"eslint": "^9",
"eslint-config-next": "15.0.4",
"maildev": "^2.2.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5"
"typescript": "^5",
"vitest": "^1.6.1"
}
}
+1
View File
@@ -0,0 +1 @@
+2
View File
@@ -0,0 +1,2 @@
[target.x86_64-pc-windows-gnu]
linker = "C:\\Users\\Hannah Kaur Bagga\\.rustup\\toolchains\\stable-x86_64-pc-windows-gnu\\lib\\rustlib\\x86_64-pc-windows-gnu\\bin\\rust-lld.exe"
+2687
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "crm-ai"
version = "0.1.0"
edition = "2021"
description = "AI Sales Assistant backend for Coast IT CRM"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "process"] }
reqwest = { version = "0.12", features = ["json", "blocking"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.5", features = ["cors"] }
dotenvy = "0.15"
rand = "0.8"
jsonwebtoken = "9"
+142
View File
@@ -0,0 +1,142 @@
# CRM AI Sales Assistant — Self-Knowledge
## Identity
You are the CRM AI Sales Assistant for Coast IT CRM.
You run on a Node.js backend (port 3001) and use Ollama with a local model (dolphin3-llama3.2:3b).
Your purpose is to help salespeople close more deals by finding and engaging leads.
## Architecture
```
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
PostgreSQL (conversations)
Python Scraper (:3008) — Facebook scraping via Playwright
```
Three services run concurrently:
- **AI Server** (`ai-server/index.mjs`, port 3001) — chat, setup wizard, config endpoints
- **Frontend** (Next.js, port 3006) — UI for salespeople
- **Scraper** (`browser-use-service/main.py`, port 3008) — Facebook lead discovery
## Capabilities
- Give sales tips and strategies per job category
- Generate cold email and outreach templates
- Handle objections with proven rebuttals
- Analyse prospect behaviour and suggest next steps
- Remember past conversations via PostgreSQL (`ai_conversations` table)
- Run Facebook scraper to find real leads asking for services
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
## Facebook Scraper
The scraper lives at `browser-use-service/main.py` port 3008.
### How It Works
1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
3. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
6. **Stealth mechanics**:
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
- Random decoy page visits (e.g., Facebook Groups) between searches
- Profile directory is temp-copied and cleaned up after each scrape
- Detection signal monitoring (checkpoint, login pages, security challenges)
7. **2-pass classification (dead-accurate)**:
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
### Lead Categories
Two categories, selectable when starting a scrape:
**Website Creation:**
- Target: people explicitly REQUESTING a website built/designed/created for them
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
- Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
**Tutoring:**
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
- Request terms: same as website category — must co-occur with a target keyword
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
### Multi-Language Pipeline (Phase Order)
4 South African languages in structured phases:
- **Phase 1 (English)**: primary query + supplementary English searches
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
### Output Format
Each lead returned includes:
- `title` — post preview text
- `author` — poster's name (may include location in name)
- `content` — extracted post text
- `url` — direct link to the post
- `date` — when posted (filtered within 2 days)
- `category` — "website" or "tutor"
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
### Configuration via Env Vars
- `SELECTED_BROWSER``firefox` (default), `chrome`, `opera`, `edge`, or `auto`
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
## How to Start Scraping
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
2. Open the frontend at `http://localhost:3006`
3. Select a job category (Website Creation or Tutoring)
4. Click "Search Facebook" — the scraper runs and returns leads
5. Leads are saved in the CRM for follow-up
## Sales Tips
- Cold emails should be under 150 words
- Follow up within 48 hours
- Personalise every outreach with the prospect's name and company
- Use open-ended questions in discovery calls
- Always ask for the next step before ending a call
- For website leads: mention specific pages or features they requested
- For tutoring leads: reference the subject and age group they mentioned
## Job Targeting
- Developers respond best to technical value props
- Marketing managers care about ROI and metrics
- C-level executives want brevity and business impact
- Parents hiring tutors: empathy and qualifications matter most
## Response Rules
- Be direct and actionable — no fluff, no AI disclaimers
- Use short paragraphs and bullet points
- Never mention being an AI or language model
- If you don't know something, say so honestly
- Prioritise the user's role: salespeople need speed, admins need control
- When asked about scraping, give specific guidance on categories and languages
## Self-Improvement Protocol
1. You notice a gap in your knowledge or a pattern in user questions
2. You call `POST /ai/instructions` with:
- `entry`: description of the improvement
- `content`: optional full replacement of ai.md
3. The improvement is logged and loaded into the next system prompt
## Improvement Log
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
+671
View File
@@ -0,0 +1,671 @@
use axum::{
extract::State,
http::{HeaderMap, HeaderValue, Method, StatusCode},
routing::{get, post},
Json, Router,
};
use tower_http::cors::{CorsLayer, AllowOrigin, Any};
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions;
use std::collections::HashMap;
use std::fs;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{error, info, warn};
use uuid::Uuid;
use rand::Rng;
use chrono::Timelike;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
// ── JWT Claims ────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct Claims {
#[serde(rename = "userId")]
user_id: String,
role: String,
}
fn verify_jwt(token: &str, secret: &str) -> Option<Claims> {
let key = DecodingKey::from_secret(secret.as_bytes());
let validation = Validation::new(Algorithm::HS256);
decode::<Claims>(token, &key, &validation).ok().map(|d| d.claims)
}
// ── Rate limiter ──────────────────────────────────────────────
struct RateLimiter {
buckets: Mutex<HashMap<String, Vec<u64>>>,
max_requests: usize,
window_secs: u64,
}
impl RateLimiter {
fn new(max_requests: usize, window_secs: u64) -> Self {
Self {
buckets: Mutex::new(HashMap::new()),
max_requests,
window_secs,
}
}
async fn check(&self, key: &str) -> bool {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let mut buckets = self.buckets.lock().await;
let timestamps = buckets.entry(key.to_string()).or_default();
timestamps.retain(|t| now - *t <= self.window_secs);
if timestamps.len() >= self.max_requests {
false
} else {
timestamps.push(now);
true
}
}
}
// ── Shared state ───────────────────────────────────────────────
struct AppState {
db: sqlx::PgPool,
ollama_url: String,
model: String,
jobs: Vec<Job>,
leads: Arc<Mutex<LeadStore>>,
http_client: reqwest::Client,
jwt_secret: String,
rate_limiter: RateLimiter,
}
#[derive(Debug, Clone, Serialize)]
struct Lead {
title: String,
url: String,
source: String,
found_at: u64,
author: String,
date: String,
content: String,
}
struct LeadStore {
leads: Vec<Lead>,
max_size: usize,
}
#[derive(Debug, Deserialize)]
struct ScrapeResponse {
success: bool,
leads: Vec<ScrapeLead>,
flagged: bool,
flag_reason: Option<String>,
error: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct ScrapeLead {
title: String,
url: String,
author: String,
date: String,
content: String,
source: Option<String>,
}
impl LeadStore {
fn new(max_size: usize) -> Self {
Self { leads: Vec::new(), max_size }
}
fn push(&mut self, lead: Lead) {
if !self.leads.iter().any(|l| l.url == lead.url) {
self.leads.insert(0, lead);
self.leads.truncate(self.max_size);
}
}
fn recent(&self, max_age_secs: u64, limit: usize) -> Vec<Lead> {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
self.leads.iter()
.filter(|l| now.saturating_sub(l.found_at) <= max_age_secs)
.take(limit)
.cloned()
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Job {
job_title: String,
keywords: Vec<String>,
industry: String,
description: String,
}
#[derive(Debug, Deserialize)]
struct ChatRequest {
message: String,
}
#[derive(Debug, Serialize)]
struct ChatResponse {
response: String,
}
#[derive(Debug, Serialize)]
struct JobsResponse {
jobs: Vec<Job>,
}
#[derive(Debug, Serialize)]
struct HealthResponse {
status: String,
model: String,
}
// ── Ollama API types ───────────────────────────────────────────
#[derive(Debug, Serialize)]
struct OllamaChatMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize)]
struct OllamaRequest {
model: String,
messages: Vec<OllamaChatMessage>,
stream: bool,
options: OllamaOptions,
}
#[derive(Debug, Serialize)]
struct OllamaOptions {
temperature: f32,
num_predict: u32,
}
#[derive(Debug, Deserialize)]
struct OllamaResponse {
message: Option<OllamaResponseMessage>,
}
#[derive(Debug, Deserialize)]
struct OllamaResponseMessage {
content: String,
}
// ── Helpers ────────────────────────────────────────────────────
fn truncate(s: &str, max: usize) -> String {
s.chars().take(max).collect()
}
fn extract_claims(headers: &HeaderMap, state: &AppState) -> Result<Claims, (StatusCode, String)> {
let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()).unwrap_or("");
let token = auth_header.strip_prefix("Bearer ").unwrap_or("");
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
})?;
match claims.role.to_lowercase().as_str() {
"sales" | "admin" | "super_admin" => Ok(claims),
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
}
}
fn format_leads_output(leads: &[Lead]) -> String {
if leads.is_empty() {
return "No new requests found yet.".to_string();
}
leads
.iter()
.enumerate()
.map(|(i, l)| {
let author = if l.author.is_empty() { "Unknown" } else { &l.author };
let date = truncate(&l.date, 10);
format!(
"{}. {}\n {}\n {}\n {}",
i + 1,
author,
date,
l.title,
l.url
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn build_system_prompt(jobs: &[Job], leads: &[Lead]) -> String {
let job_list: Vec<String> = jobs
.iter()
.map(|j| format!("- {} ({}): {}", j.job_title, j.industry, j.description))
.collect();
let job_list_str = job_list.join("\n");
let lead_summary: Vec<String> = leads
.iter()
.map(|l| format!("{} | {} | {}", l.author, l.title, l.url))
.collect();
let lead_summary_str = if lead_summary.is_empty() {
"None yet.".to_string()
} else {
lead_summary.join("\n")
};
format!(
"You are a Sales AI Assistant for Coast IT CRM.\n\n\
Available job categories to target:\n{}\n\n\
Recent leads context:\n{}\n\n\
Rules:\n\
- When asked about leads, answer concisely under 150 words.\n\
- If asked to suggest a sales strategy, give brief actionable advice.\n\
- Be direct and professional. No fluff.",
job_list_str, lead_summary_str
)
}
// ── Chat handler ───────────────────────────────────────────────
async fn handle_chat(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ChatRequest>,
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
let claims = extract_claims(&headers, &state)?;
if !state.rate_limiter.check(&claims.user_id).await {
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
}
let msg_lower = req.message.to_lowercase();
let msg_words: Vec<&str> = msg_lower.split_whitespace().collect();
let has_listing = msg_words.iter().any(|w| ["listings", "listing", "leads", "links", "lists"].contains(w));
let has_show = msg_lower.contains("show me") || msg_lower.contains("give me") || msg_lower.contains("pull");
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let base_url = "http://localhost:3008/scrape/facebook";
use std::fmt::Write;
let mut service_url = base_url.to_string();
if let Ok(Some((_, path))) = sqlx::query_as::<_, (uuid::Uuid, String)>(
"SELECT id, profile_path FROM facebook_accounts \
WHERE is_active = TRUE AND flagged = FALSE \
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
)
.fetch_optional(&state.db)
.await
{
let encoded: String = path.chars().map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' | '~' => c.to_string(),
_ => format!("%{:02X}", c as u8),
}).collect();
write!(service_url, "?profile_path={}&force=true", encoded).unwrap();
info!("Calling Python scrape at: {}?profile_path=...&force=true", base_url);
} else {
warn!("No active Facebook account found for on-demand scrape");
}
let req_builder = state.http_client.post(&service_url);
match req_builder.send().await {
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
info!("Python scrape response ({}): {} bytes", status, body.len());
if body.starts_with('{') {
match serde_json::from_str::<ScrapeResponse>(&body) {
Ok(scrape_resp) => {
info!("Scraped {} leads from Facebook", scrape_resp.leads.len());
if scrape_resp.leads.is_empty() && scrape_resp.error.is_some() {
warn!("Python returned error: {:?}", scrape_resp.error);
}
let mut store = state.leads.lock().await;
for item in &scrape_resp.leads {
store.push(Lead {
title: truncate(&item.title, 120),
url: item.url.clone(),
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
found_at: now,
author: truncate(&item.author, 60),
date: truncate(&item.date, 30),
content: truncate(&item.content, 300),
});
}
}
Err(e) => {
warn!("Failed to parse Python scrape response: {} body: {}", e, &body[..body.len().min(200)]);
}
}
} else {
warn!("Python returned non-JSON response: {}", &body[..body.len().min(200)]);
}
}
Err(e) => {
error!("Scraper request error: {} - URL: {}", e, base_url);
}
}
let recent_leads = state.leads.lock().await.recent(604800, 20);
let response = format_leads_output(&recent_leads);
let _ = sqlx::query(
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
)
.bind(Uuid::new_v4())
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
.bind(&claims.role)
.bind(&req.message)
.bind(&response)
.execute(&state.db)
.await;
return Ok(Json(ChatResponse { response }));
}
let recent_leads = state.leads.lock().await.recent(604800, 20);
let system_prompt = build_system_prompt(&state.jobs, &recent_leads);
let message_text = req.message.clone();
let ollama_req = OllamaRequest {
model: state.model.clone(),
messages: vec![
OllamaChatMessage { role: "system".to_string(), content: system_prompt },
OllamaChatMessage { role: "user".to_string(), content: req.message.clone() },
],
stream: false,
options: OllamaOptions { temperature: 0.7, num_predict: 1024 },
};
let resp = state.http_client
.post(format!("{}/api/chat", state.ollama_url))
.json(&ollama_req)
.send()
.await
.map_err(|e| {
error!("Ollama request failed: {}", e);
(StatusCode::SERVICE_UNAVAILABLE, "AI service unavailable".to_string())
})?;
let ollama_resp: OllamaResponse = resp.json().await.map_err(|e| {
error!("Failed to parse Ollama response: {}", e);
(StatusCode::SERVICE_UNAVAILABLE, "AI response parse error".to_string())
})?;
let response_text = ollama_resp.message.map(|m| m.content).unwrap_or_default();
let _ = sqlx::query(
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
)
.bind(Uuid::new_v4())
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
.bind(&claims.role)
.bind(&message_text)
.bind(&response_text)
.execute(&state.db)
.await;
Ok(Json(ChatResponse { response: response_text }))
}
// ── Jobs handler ───────────────────────────────────────────────
async fn handle_jobs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<JobsResponse>, (StatusCode, String)> {
let _claims = extract_claims(&headers, &state)?;
if !state.rate_limiter.check(&_claims.user_id).await {
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
}
Ok(Json(JobsResponse { jobs: state.jobs.clone() }))
}
// ── Health handler ─────────────────────────────────────────────
async fn handle_health(
State(state): State<Arc<AppState>>,
) -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".to_string(),
model: state.model.clone(),
})
}
// ── Main ───────────────────────────────────────────────────────
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "crm_ai=info,tower_http=info".into()),
)
.init();
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
let jobs_path = std::env::var("JOBS_PATH").unwrap_or_else(|_| "data/ai/jobs.jsonl".to_string());
let jobs_content = fs::read_to_string(&jobs_path).unwrap_or_default();
let jobs: Vec<Job> = jobs_content.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect();
info!("Loaded {} job categories, model: {}, Ollama: {}", jobs.len(), model, ollama_url);
let db = PgPoolOptions::new()
.max_connections(20)
.connect(&database_url)
.await
.expect("Failed to connect to database");
info!("Connected to PostgreSQL");
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.build()
.expect("Failed to build HTTP client");
let lead_store = Arc::new(Mutex::new(LeadStore::new(100)));
let state = Arc::new(AppState {
db,
ollama_url,
model,
jobs,
leads: lead_store.clone(),
http_client,
jwt_secret,
rate_limiter: RateLimiter::new(30, 60),
});
let cors_origins_env = std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "http://localhost:3006,http://127.0.0.1:3006".to_string());
let cors_origins: Vec<HeaderValue> = cors_origins_env.split(',')
.filter_map(|o| { let t = o.trim(); if t.is_empty() { None } else { t.parse().ok() } })
.collect();
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list(cors_origins))
.allow_methods([Method::GET, Method::POST])
.allow_headers(Any);
let app = Router::new()
.route("/ai/chat", post(handle_chat))
.route("/ai/jobs", get(handle_jobs))
.route("/health", get(handle_health))
.layer(cors)
.with_state(state.clone());
let addr = format!("{}:{}", host, port);
info!("CRM AI server listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("Failed to bind address");
let bg_leads = lead_store.clone();
let bg_db = state.db.clone();
let bg_url = std::env::var("SCRAPER_URL").unwrap_or_else(|_| "http://localhost:3008".to_string()) + "/scrape/facebook";
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.build()
{
Ok(c) => c,
Err(e) => {
error!("Failed to build background HTTP client: {} — scraper disabled", e);
return;
}
};
// Initial delay to let user on-demand requests take priority
tokio::time::sleep(Duration::from_secs(300)).await;
loop {
// 10% random cycle skip — makes pattern non-periodic
if rand::thread_rng().gen_range(0..100) < 10 {
info!("Skipping this scrape cycle (random 10% skip)");
let jitter = rand::thread_rng().gen_range(16200..19800);
tokio::time::sleep(Duration::from_secs(jitter)).await;
continue;
}
// Skip night hours (23:00 06:00)
let hour = chrono::Local::now().hour();
if hour < 6 || hour >= 23 {
info!("Night hours ({}) — skipping scrape", hour);
let jitter = rand::thread_rng().gen_range(16200..19800);
tokio::time::sleep(Duration::from_secs(jitter)).await;
continue;
}
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
// Pick next active un-flagged account
let account = sqlx::query_as::<_, (uuid::Uuid, String)>(
"SELECT id, profile_path FROM facebook_accounts \
WHERE is_active = TRUE AND flagged = FALSE \
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
)
.fetch_optional(&bg_db)
.await;
match account {
Ok(Some((account_id, profile_path))) => {
match client.post(&bg_url).query(&[("profile_path", &profile_path)]).send().await {
Ok(resp) => {
if resp.status().is_success() {
match resp.json::<ScrapeResponse>().await {
Ok(data) => {
let leads_count = data.leads.len() as i32;
if data.flagged {
let _ = sqlx::query(
"UPDATE facebook_accounts SET flagged = TRUE, flagged_at = NOW(), \
flagged_reason = $2, last_error_at = NOW(), \
last_error_message = $3, consecutive_failures = consecutive_failures + 1 \
WHERE id = $1"
)
.bind(account_id)
.bind(&data.flag_reason)
.bind(&data.error)
.execute(&bg_db)
.await;
warn!("Facebook account {} flagged: {:?}", account_id, data.flag_reason);
let reason = data.flag_reason.as_deref().unwrap_or("unknown");
let _ = sqlx::query(
"INSERT INTO notifications (user_id, type, title, description, link) \
SELECT id, 'warning', 'Facebook Account Flagged', \
$1 || ' - ' || COALESCE($2, 'unknown reason'), \
NULL \
FROM users u JOIN user_roles ur ON ur.user_id = u.id \
JOIN roles r ON r.id = ur.role_id \
WHERE r.name IN ('ADMIN', 'SUPER_ADMIN')"
)
.bind(&account_id.to_string())
.bind(reason)
.execute(&bg_db)
.await;
} else if data.success {
let _ = sqlx::query(
"UPDATE facebook_accounts SET last_scrape_at = NOW(), \
last_success_at = NOW(), consecutive_failures = 0, \
updated_at = NOW() WHERE id = $1"
)
.bind(account_id)
.execute(&bg_db)
.await;
let mut store = bg_leads.lock().await;
for item in &data.leads {
store.push(Lead {
title: truncate(&item.title, 120),
url: item.url.clone(),
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
found_at: now,
author: truncate(&item.author, 60),
date: truncate(&item.date, 30),
content: truncate(&item.content, 300),
});
}
info!("Scraped {} leads from Facebook account {}", leads_count, account_id);
} else {
// Increment failures; auto-flag if >= 3 consecutive
let _ = sqlx::query(
"UPDATE facebook_accounts SET last_error_at = NOW(), \
last_error_message = $2, consecutive_failures = consecutive_failures + 1, \
flagged = CASE WHEN consecutive_failures + 1 >= 3 THEN TRUE ELSE flagged END, \
flagged_reason = CASE WHEN consecutive_failures + 1 >= 3 THEN 'too_many_failures' ELSE flagged_reason END, \
flagged_at = CASE WHEN consecutive_failures + 1 >= 3 THEN NOW() ELSE flagged_at END, \
updated_at = NOW() WHERE id = $1"
)
.bind(account_id)
.bind(&data.error)
.execute(&bg_db)
.await;
warn!("Facebook scrape failed for account {}: {:?}", account_id, data.error);
}
let _ = sqlx::query(
"INSERT INTO facebook_scrape_logs \
(account_id, started_at, completed_at, success, leads_found, error_message, detected_flag) \
VALUES ($1, NOW() - interval '5 hours', NOW(), $2, $3, $4, $5)"
)
.bind(account_id)
.bind(data.success && !data.flagged)
.bind(leads_count)
.bind(&data.error)
.bind(&data.flag_reason)
.execute(&bg_db)
.await;
}
Err(e) => {
warn!("Failed to parse scraper JSON: {}", e);
}
}
} else {
warn!("Scraper returned status: {}", resp.status());
}
}
Err(e) => {
warn!("Scraper request failed: {}", e);
}
}
}
Ok(None) => {
info!("No active Facebook accounts available — skipping scrape cycle");
}
Err(e) => {
warn!("Failed to query Facebook accounts: {}", e);
}
}
let jitter = rand::thread_rng().gen_range(16200..19800); // 4.5h 5.5h
tokio::time::sleep(Duration::from_secs(jitter)).await;
}
});
axum::serve(listener, app)
.await
.expect("Server failed");
}
+100
View File
@@ -0,0 +1,100 @@
const express = require('express');
const { chromium } = require('playwright');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3007;
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.post('/scrape/indeed', async (req, res) => {
const results = [];
let browser;
try {
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
const queries = [
'need a website built',
'looking for someone to build my website',
'need web designer',
'looking for web developer',
'need help with my website',
'need ecommerce website',
'need wordpress website',
'looking for website designer',
];
for (const q of queries) {
const url = `https://www.indeed.com/jobs?q=${encodeURIComponent(q)}&sort=date`;
try {
const page = await context.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.waitForTimeout(2000);
const items = await page.evaluate(() => {
const cards = document.querySelectorAll('.job_seen_beacon');
return Array.from(cards).slice(0, 8).map(card => {
const titleEl = card.querySelector('.jcs-JobTitle');
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"]');
const snippetEl = card.querySelector('.job-snippet');
return {
title: titleEl?.textContent?.trim() || '',
url: titleEl?.href || '',
company: companyEl?.textContent?.trim() || '',
snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '',
};
});
});
for (const item of items) {
if (item.title && item.url && !results.some(r => r.url === item.url)) {
results.push({
title: item.title,
url: item.url,
author: item.company,
date: '',
content: item.snippet,
source: 'indeed',
});
}
}
await page.close();
} catch (e) {
console.error(`Indeed failed ${q}:`, e.message);
}
}
await browser.close();
res.json(results.slice(0, 50));
} catch (e) {
if (browser) await browser.close().catch(() => {});
console.error('Indeed error:', e.message);
res.status(500).json({ error: e.message });
}
});
app.post('/scrape/all', async (req, res) => {
try {
const [indeed] = await Promise.allSettled([
fetch(`http://localhost:${PORT}/scrape/indeed`, { method: 'POST' }).then(r => r.json()).catch(() => []),
]);
const all = [
...(indeed.status === 'fulfilled' ? indeed.value : []),
];
console.log(`Scrape all: ${all.length} leads`);
res.json(all);
} catch (e) {
console.error('Scrape all error:', e.message);
res.status(500).json({ error: e.message });
}
});
app.listen(PORT, () => {
console.log(`Scraper service running on port ${PORT}`);
});
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../mime/cli.js" "$@"
else
exec node "$basedir/../mime/cli.js" "$@"
fi
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\mime\cli.js" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../mime/cli.js" $args
} else {
& "$basedir/node$exe" "$basedir/../mime/cli.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../mime/cli.js" $args
} else {
& "node$exe" "$basedir/../mime/cli.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../playwright/cli.js" "$@"
else
exec node "$basedir/../playwright/cli.js" "$@"
fi
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../playwright-core/cli.js" "$@"
else
exec node "$basedir/../playwright-core/cli.js" "$@"
fi
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\playwright-core\cli.js" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../playwright-core/cli.js" $args
} else {
& "$basedir/node$exe" "$basedir/../playwright-core/cli.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../playwright-core/cli.js" $args
} else {
& "node$exe" "$basedir/../playwright-core/cli.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\playwright\cli.js" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../playwright/cli.js" $args
} else {
& "$basedir/node$exe" "$basedir/../playwright/cli.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../playwright/cli.js" $args
} else {
& "node$exe" "$basedir/../playwright/cli.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+850
View File
@@ -0,0 +1,850 @@
{
"name": "scraper-service",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/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/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
+243
View File
@@ -0,0 +1,243 @@
1.3.8 / 2022-02-02
==================
* deps: mime-types@~2.1.34
- deps: mime-db@~1.51.0
* deps: negotiator@0.6.3
1.3.7 / 2019-04-29
==================
* deps: negotiator@0.6.2
- Fix sorting charset, encoding, and language with extra parameters
1.3.6 / 2019-04-28
==================
* deps: mime-types@~2.1.24
- deps: mime-db@~1.40.0
1.3.5 / 2018-02-28
==================
* deps: mime-types@~2.1.18
- deps: mime-db@~1.33.0
1.3.4 / 2017-08-22
==================
* deps: mime-types@~2.1.16
- deps: mime-db@~1.29.0
1.3.3 / 2016-05-02
==================
* deps: mime-types@~2.1.11
- deps: mime-db@~1.23.0
* deps: negotiator@0.6.1
- perf: improve `Accept` parsing speed
- perf: improve `Accept-Charset` parsing speed
- perf: improve `Accept-Encoding` parsing speed
- perf: improve `Accept-Language` parsing speed
1.3.2 / 2016-03-08
==================
* deps: mime-types@~2.1.10
- Fix extension of `application/dash+xml`
- Update primary extension for `audio/mp4`
- deps: mime-db@~1.22.0
1.3.1 / 2016-01-19
==================
* deps: mime-types@~2.1.9
- deps: mime-db@~1.21.0
1.3.0 / 2015-09-29
==================
* deps: mime-types@~2.1.7
- deps: mime-db@~1.19.0
* deps: negotiator@0.6.0
- Fix including type extensions in parameters in `Accept` parsing
- Fix parsing `Accept` parameters with quoted equals
- Fix parsing `Accept` parameters with quoted semicolons
- Lazy-load modules from main entry point
- perf: delay type concatenation until needed
- perf: enable strict mode
- perf: hoist regular expressions
- perf: remove closures getting spec properties
- perf: remove a closure from media type parsing
- perf: remove property delete from media type parsing
1.2.13 / 2015-09-06
===================
* deps: mime-types@~2.1.6
- deps: mime-db@~1.18.0
1.2.12 / 2015-07-30
===================
* deps: mime-types@~2.1.4
- deps: mime-db@~1.16.0
1.2.11 / 2015-07-16
===================
* deps: mime-types@~2.1.3
- deps: mime-db@~1.15.0
1.2.10 / 2015-07-01
===================
* deps: mime-types@~2.1.2
- deps: mime-db@~1.14.0
1.2.9 / 2015-06-08
==================
* deps: mime-types@~2.1.1
- perf: fix deopt during mapping
1.2.8 / 2015-06-07
==================
* deps: mime-types@~2.1.0
- deps: mime-db@~1.13.0
* perf: avoid argument reassignment & argument slice
* perf: avoid negotiator recursive construction
* perf: enable strict mode
* perf: remove unnecessary bitwise operator
1.2.7 / 2015-05-10
==================
* deps: negotiator@0.5.3
- Fix media type parameter matching to be case-insensitive
1.2.6 / 2015-05-07
==================
* deps: mime-types@~2.0.11
- deps: mime-db@~1.9.1
* deps: negotiator@0.5.2
- Fix comparing media types with quoted values
- Fix splitting media types with quoted commas
1.2.5 / 2015-03-13
==================
* deps: mime-types@~2.0.10
- deps: mime-db@~1.8.0
1.2.4 / 2015-02-14
==================
* Support Node.js 0.6
* deps: mime-types@~2.0.9
- deps: mime-db@~1.7.0
* deps: negotiator@0.5.1
- Fix preference sorting to be stable for long acceptable lists
1.2.3 / 2015-01-31
==================
* deps: mime-types@~2.0.8
- deps: mime-db@~1.6.0
1.2.2 / 2014-12-30
==================
* deps: mime-types@~2.0.7
- deps: mime-db@~1.5.0
1.2.1 / 2014-12-30
==================
* deps: mime-types@~2.0.5
- deps: mime-db@~1.3.1
1.2.0 / 2014-12-19
==================
* deps: negotiator@0.5.0
- Fix list return order when large accepted list
- Fix missing identity encoding when q=0 exists
- Remove dynamic building of Negotiator class
1.1.4 / 2014-12-10
==================
* deps: mime-types@~2.0.4
- deps: mime-db@~1.3.0
1.1.3 / 2014-11-09
==================
* deps: mime-types@~2.0.3
- deps: mime-db@~1.2.0
1.1.2 / 2014-10-14
==================
* deps: negotiator@0.4.9
- Fix error when media type has invalid parameter
1.1.1 / 2014-09-28
==================
* deps: mime-types@~2.0.2
- deps: mime-db@~1.1.0
* deps: negotiator@0.4.8
- Fix all negotiations to be case-insensitive
- Stable sort preferences of same quality according to client order
1.1.0 / 2014-09-02
==================
* update `mime-types`
1.0.7 / 2014-07-04
==================
* Fix wrong type returned from `type` when match after unknown extension
1.0.6 / 2014-06-24
==================
* deps: negotiator@0.4.7
1.0.5 / 2014-06-20
==================
* fix crash when unknown extension given
1.0.4 / 2014-06-19
==================
* use `mime-types`
1.0.3 / 2014-06-11
==================
* deps: negotiator@0.4.6
- Order by specificity when quality is the same
1.0.2 / 2014-05-29
==================
* Fix interpretation when header not in request
* deps: pin negotiator@0.4.5
1.0.1 / 2014-01-18
==================
* Identity encoding isn't always acceptable
* deps: negotiator@~0.4.0
1.0.0 / 2013-12-27
==================
* Genesis
+23
View File
@@ -0,0 +1,23 @@
(The MIT License)
Copyright (c) 2014 Jonathan Ong <me@jongleberry.com>
Copyright (c) 2015 Douglas Christopher Wilson <doug@somethingdoug.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+140
View File
@@ -0,0 +1,140 @@
# accepts
[![NPM Version][npm-version-image]][npm-url]
[![NPM Downloads][npm-downloads-image]][npm-url]
[![Node.js Version][node-version-image]][node-version-url]
[![Build Status][github-actions-ci-image]][github-actions-ci-url]
[![Test Coverage][coveralls-image]][coveralls-url]
Higher level content negotiation based on [negotiator](https://www.npmjs.com/package/negotiator).
Extracted from [koa](https://www.npmjs.com/package/koa) for general use.
In addition to negotiator, it allows:
- Allows types as an array or arguments list, ie `(['text/html', 'application/json'])`
as well as `('text/html', 'application/json')`.
- Allows type shorthands such as `json`.
- Returns `false` when no types match
- Treats non-existent headers as `*`
## Installation
This is a [Node.js](https://nodejs.org/en/) module available through the
[npm registry](https://www.npmjs.com/). Installation is done using the
[`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally):
```sh
$ npm install accepts
```
## API
```js
var accepts = require('accepts')
```
### accepts(req)
Create a new `Accepts` object for the given `req`.
#### .charset(charsets)
Return the first accepted charset. If nothing in `charsets` is accepted,
then `false` is returned.
#### .charsets()
Return the charsets that the request accepts, in the order of the client's
preference (most preferred first).
#### .encoding(encodings)
Return the first accepted encoding. If nothing in `encodings` is accepted,
then `false` is returned.
#### .encodings()
Return the encodings that the request accepts, in the order of the client's
preference (most preferred first).
#### .language(languages)
Return the first accepted language. If nothing in `languages` is accepted,
then `false` is returned.
#### .languages()
Return the languages that the request accepts, in the order of the client's
preference (most preferred first).
#### .type(types)
Return the first accepted type (and it is returned as the same text as what
appears in the `types` array). If nothing in `types` is accepted, then `false`
is returned.
The `types` array can contain full MIME types or file extensions. Any value
that is not a full MIME types is passed to `require('mime-types').lookup`.
#### .types()
Return the types that the request accepts, in the order of the client's
preference (most preferred first).
## Examples
### Simple type negotiation
This simple example shows how to use `accepts` to return a different typed
respond body based on what the client wants to accept. The server lists it's
preferences in order and will get back the best match between the client and
server.
```js
var accepts = require('accepts')
var http = require('http')
function app (req, res) {
var accept = accepts(req)
// the order of this list is significant; should be server preferred order
switch (accept.type(['json', 'html'])) {
case 'json':
res.setHeader('Content-Type', 'application/json')
res.write('{"hello":"world!"}')
break
case 'html':
res.setHeader('Content-Type', 'text/html')
res.write('<b>hello, world!</b>')
break
default:
// the fallback is text/plain, so no need to specify it above
res.setHeader('Content-Type', 'text/plain')
res.write('hello, world!')
break
}
res.end()
}
http.createServer(app).listen(3000)
```
You can test this out with the cURL program:
```sh
curl -I -H'Accept: text/html' http://localhost:3000/
```
## License
[MIT](LICENSE)
[coveralls-image]: https://badgen.net/coveralls/c/github/jshttp/accepts/master
[coveralls-url]: https://coveralls.io/r/jshttp/accepts?branch=master
[github-actions-ci-image]: https://badgen.net/github/checks/jshttp/accepts/master?label=ci
[github-actions-ci-url]: https://github.com/jshttp/accepts/actions/workflows/ci.yml
[node-version-image]: https://badgen.net/npm/node/accepts
[node-version-url]: https://nodejs.org/en/download
[npm-downloads-image]: https://badgen.net/npm/dm/accepts
[npm-url]: https://npmjs.org/package/accepts
[npm-version-image]: https://badgen.net/npm/v/accepts
+238
View File
@@ -0,0 +1,238 @@
/*!
* accepts
* Copyright(c) 2014 Jonathan Ong
* Copyright(c) 2015 Douglas Christopher Wilson
* MIT Licensed
*/
'use strict'
/**
* Module dependencies.
* @private
*/
var Negotiator = require('negotiator')
var mime = require('mime-types')
/**
* Module exports.
* @public
*/
module.exports = Accepts
/**
* Create a new Accepts object for the given req.
*
* @param {object} req
* @public
*/
function Accepts (req) {
if (!(this instanceof Accepts)) {
return new Accepts(req)
}
this.headers = req.headers
this.negotiator = new Negotiator(req)
}
/**
* Check if the given `type(s)` is acceptable, returning
* the best match when true, otherwise `undefined`, in which
* case you should respond with 406 "Not Acceptable".
*
* The `type` value may be a single mime type string
* such as "application/json", the extension name
* such as "json" or an array `["json", "html", "text/plain"]`. When a list
* or array is given the _best_ match, if any is returned.
*
* Examples:
*
* // Accept: text/html
* this.types('html');
* // => "html"
*
* // Accept: text/*, application/json
* this.types('html');
* // => "html"
* this.types('text/html');
* // => "text/html"
* this.types('json', 'text');
* // => "json"
* this.types('application/json');
* // => "application/json"
*
* // Accept: text/*, application/json
* this.types('image/png');
* this.types('png');
* // => undefined
*
* // Accept: text/*;q=.5, application/json
* this.types(['html', 'json']);
* this.types('html', 'json');
* // => "json"
*
* @param {String|Array} types...
* @return {String|Array|Boolean}
* @public
*/
Accepts.prototype.type =
Accepts.prototype.types = function (types_) {
var types = types_
// support flattened arguments
if (types && !Array.isArray(types)) {
types = new Array(arguments.length)
for (var i = 0; i < types.length; i++) {
types[i] = arguments[i]
}
}
// no types, return all requested types
if (!types || types.length === 0) {
return this.negotiator.mediaTypes()
}
// no accept header, return first given type
if (!this.headers.accept) {
return types[0]
}
var mimes = types.map(extToMime)
var accepts = this.negotiator.mediaTypes(mimes.filter(validMime))
var first = accepts[0]
return first
? types[mimes.indexOf(first)]
: false
}
/**
* Return accepted encodings or best fit based on `encodings`.
*
* Given `Accept-Encoding: gzip, deflate`
* an array sorted by quality is returned:
*
* ['gzip', 'deflate']
*
* @param {String|Array} encodings...
* @return {String|Array}
* @public
*/
Accepts.prototype.encoding =
Accepts.prototype.encodings = function (encodings_) {
var encodings = encodings_
// support flattened arguments
if (encodings && !Array.isArray(encodings)) {
encodings = new Array(arguments.length)
for (var i = 0; i < encodings.length; i++) {
encodings[i] = arguments[i]
}
}
// no encodings, return all requested encodings
if (!encodings || encodings.length === 0) {
return this.negotiator.encodings()
}
return this.negotiator.encodings(encodings)[0] || false
}
/**
* Return accepted charsets or best fit based on `charsets`.
*
* Given `Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5`
* an array sorted by quality is returned:
*
* ['utf-8', 'utf-7', 'iso-8859-1']
*
* @param {String|Array} charsets...
* @return {String|Array}
* @public
*/
Accepts.prototype.charset =
Accepts.prototype.charsets = function (charsets_) {
var charsets = charsets_
// support flattened arguments
if (charsets && !Array.isArray(charsets)) {
charsets = new Array(arguments.length)
for (var i = 0; i < charsets.length; i++) {
charsets[i] = arguments[i]
}
}
// no charsets, return all requested charsets
if (!charsets || charsets.length === 0) {
return this.negotiator.charsets()
}
return this.negotiator.charsets(charsets)[0] || false
}
/**
* Return accepted languages or best fit based on `langs`.
*
* Given `Accept-Language: en;q=0.8, es, pt`
* an array sorted by quality is returned:
*
* ['es', 'pt', 'en']
*
* @param {String|Array} langs...
* @return {Array|String}
* @public
*/
Accepts.prototype.lang =
Accepts.prototype.langs =
Accepts.prototype.language =
Accepts.prototype.languages = function (languages_) {
var languages = languages_
// support flattened arguments
if (languages && !Array.isArray(languages)) {
languages = new Array(arguments.length)
for (var i = 0; i < languages.length; i++) {
languages[i] = arguments[i]
}
}
// no languages, return all requested languages
if (!languages || languages.length === 0) {
return this.negotiator.languages()
}
return this.negotiator.languages(languages)[0] || false
}
/**
* Convert extnames to mime.
*
* @param {String} type
* @return {String}
* @private
*/
function extToMime (type) {
return type.indexOf('/') === -1
? mime.lookup(type)
: type
}
/**
* Check if mime is valid.
*
* @param {String} type
* @return {String}
* @private
*/
function validMime (type) {
return typeof type === 'string'
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "accepts",
"description": "Higher-level content negotiation",
"version": "1.3.8",
"contributors": [
"Douglas Christopher Wilson <doug@somethingdoug.com>",
"Jonathan Ong <me@jongleberry.com> (http://jongleberry.com)"
],
"license": "MIT",
"repository": "jshttp/accepts",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"devDependencies": {
"deep-equal": "1.0.1",
"eslint": "7.32.0",
"eslint-config-standard": "14.1.1",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-markdown": "2.2.1",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-promise": "4.3.1",
"eslint-plugin-standard": "4.1.0",
"mocha": "9.2.0",
"nyc": "15.1.0"
},
"files": [
"LICENSE",
"HISTORY.md",
"index.js"
],
"engines": {
"node": ">= 0.6"
},
"scripts": {
"lint": "eslint .",
"test": "mocha --reporter spec --check-leaks --bail test/",
"test-ci": "nyc --reporter=lcov --reporter=text npm test",
"test-cov": "nyc --reporter=html --reporter=text npm test"
},
"keywords": [
"content",
"negotiation",
"accept",
"accepts"
]
}
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+43
View File
@@ -0,0 +1,43 @@
# Array Flatten
[![NPM version][npm-image]][npm-url]
[![NPM downloads][downloads-image]][downloads-url]
[![Build status][travis-image]][travis-url]
[![Test coverage][coveralls-image]][coveralls-url]
> Flatten an array of nested arrays into a single flat array. Accepts an optional depth.
## Installation
```
npm install array-flatten --save
```
## Usage
```javascript
var flatten = require('array-flatten')
flatten([1, [2, [3, [4, [5], 6], 7], 8], 9])
//=> [1, 2, 3, 4, 5, 6, 7, 8, 9]
flatten([1, [2, [3, [4, [5], 6], 7], 8], 9], 2)
//=> [1, 2, 3, [4, [5], 6], 7, 8, 9]
(function () {
flatten(arguments) //=> [1, 2, 3]
})(1, [2, 3])
```
## License
MIT
[npm-image]: https://img.shields.io/npm/v/array-flatten.svg?style=flat
[npm-url]: https://npmjs.org/package/array-flatten
[downloads-image]: https://img.shields.io/npm/dm/array-flatten.svg?style=flat
[downloads-url]: https://npmjs.org/package/array-flatten
[travis-image]: https://img.shields.io/travis/blakeembrey/array-flatten.svg?style=flat
[travis-url]: https://travis-ci.org/blakeembrey/array-flatten
[coveralls-image]: https://img.shields.io/coveralls/blakeembrey/array-flatten.svg?style=flat
[coveralls-url]: https://coveralls.io/r/blakeembrey/array-flatten?branch=master

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