Compare commits

...

48 Commits

Author SHA1 Message Date
caitlin ec8207fca7 Update login page styling and sidebar collapse logo 2026-06-26 16:22:28 +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
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
84 changed files with 6676 additions and 495 deletions
+1
View File
@@ -0,0 +1 @@
rust-ai/target/** linguist-generated=true -diff -merge
+3 -4
View File
@@ -47,6 +47,9 @@ error-log
# vercel # vercel
.vercel .vercel
# rust
rust-ai/target/
# python # python
browser-use-service/venv/ browser-use-service/venv/
browser-use-service/__pycache__/ browser-use-service/__pycache__/
@@ -55,7 +58,3 @@ browser-use-service/.env
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.git
.git_bak
node_modules
target
+377
View File
@@ -0,0 +1,377 @@
import http from "node:http"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.resolve(__dirname, "..")
// ── Load .env.local ──────────────────────────────────────────────
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, ignore
}
// ── 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 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")
// ── Job loading ─────────────────────────────────────────────────
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 ────────────────────────────────────────────
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) {
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 ────────────────────────────────────────────────
async function scrapeFacebook() {
const profilePath = process.env.FX_PROFILE || ""
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
const logPath = "C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-scrape-debug.log"
try {
const body = await new Promise((resolve, reject) => {
const req = http.request({ hostname: "127.0.0.1", port: 3008, path: urlPath, method: "POST", timeout: 360000 }, (res) => {
let data = ""
res.on("data", (c) => data += c)
res.on("end", () => resolve(data))
res.on("error", reject)
})
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
req.on("error", reject)
req.end()
})
const data = JSON.parse(body)
return data
} catch (e) {
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) {
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."
}
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" },
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 || ""
// Try to persist to DB (best-effort)
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) ────────────────────────────────────────
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 }
}
const server = http.createServer(async (req, res) => {
// CORS
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
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("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() })
r.on("error", reject)
})
results.scraper = true
} catch { results.scraper = false }
// Check frontend
try {
await new Promise((resolve, reject) => {
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() })
r.on("error", reject)
})
results.frontend = true
} catch { results.frontend = false }
return sendJSON(res, 200, results)
}
// GET /ai/jobs
if (req.method === "GET" && pathname === "/ai/jobs") {
return sendJSON(res, 200, { jobs: loadedJobs })
}
// GET /ai/instructions
if (req.method === "GET" && pathname === "/ai/instructions") {
const instructions = readInstructions()
return sendJSON(res, 200, { success: true, instructions })
}
// POST /ai/instructions
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
if (req.method === "POST" && pathname === "/ai/chat") {
const startTime = Date.now()
const chunks = []
req.on("data", c => chunks.push(c))
req.on("end", () => {
const rawBody = Buffer.concat(chunks).toString()
try { fs.appendFileSync("C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-req-log.txt",
`${new Date().toISOString()} headers=${JSON.stringify(req.headers)} body=${rawBody}\n`) } catch {}
try {
const body = JSON.parse(rawBody)
// Continue processing
processRequest(req, res, body, startTime)
} catch {
sendJSON(res, 400, { error: "Invalid JSON" })
}
})
return
}
// Separate handler
async function processRequest(req, res, body, startTime) {
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")
return sendJSON(res, 200, { response })
}
// 404
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()
Binary file not shown.
File diff suppressed because it is too large Load Diff
View File
-7
View File
@@ -1,7 +0,0 @@
python : INFO: Started server process [20044]
+ CategoryInfo : NotSpecified: (INFO: Started server process [20044]:String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:3008 (Press CTRL+C to quit)
-6
View File
@@ -1,6 +0,0 @@
[2026-06-22T19:24:48.030482] Browser Use service starting on port 3008
[2026-06-22T19:27:19.231701] Browser Use service starting on port 3008
[2026-06-22T19:28:28.335765] Browser Use service starting on port 3008
[2026-06-22T19:29:05.796265] Browser Use service starting on port 3008
[2026-06-22T19:29:17.042807] Scraping WarriorForum...
[2026-06-22T19:29:19.075166] WarriorForum: 55 results
-4
View File
@@ -1,4 +0,0 @@
INFO: Started server process [24268]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:3008 (Press CTRL+C to quit)
-5
View File
@@ -1,5 +0,0 @@
Browser Use service starting on port 3008
INFO: 127.0.0.1:65003 - "GET /health HTTP/1.1" 200 OK
Scraping WarriorForum...
WarriorForum: 55 results
INFO: 127.0.0.1:54587 - "POST /scrape/all HTTP/1.1" 200 OK
+4 -4
View File
@@ -126,10 +126,10 @@ separate 1:N child tables.
| Username | Password | Role | | Username | Password | Role |
|----------|----------|------| |----------|----------|------|
| `superadmin_demo` | `SuperAdmin@2026` | SUPER_ADMIN | | `superadmin_demo` | `[REDACTED]` | SUPER_ADMIN |
| `admin_demo` | `AdminAccess@2026` | ADMIN | | `admin_demo` | `[REDACTED]` | ADMIN |
| `sales_demo` | `SalesAccess@2026` | SALES_USER | | `sales_demo` | `[REDACTED]` | SALES_USER |
| `dev_demo` | `DevTesting@2026` | DEVELOPER | | `dev_demo` | `[REDACTED]` | DEVELOPER |
--- ---
@@ -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);
+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);
@@ -0,0 +1,604 @@
-- ============================================================================
-- 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;
-- ============================================================================
-- 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;
+6
View File
@@ -34,5 +34,11 @@ BEGIN;
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ===' \echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
\i 009_settings.sql \i 009_settings.sql
\echo '=== Running 013_security_upgrade.sql (Security Architecture: RLS, Encryption, Master Keys, Backups) ==='
\i 013_security_upgrade.sql
\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ==='
\i 014_bug_reports.sql
\echo '=== Migration Complete ===' \echo '=== Migration Complete ==='
COMMIT; COMMIT;
+25
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -1,4 +1,12 @@
import type { NextConfig } from "next" import type { NextConfig } from "next"
import crypto from "crypto"
// In development, generate a random JWT secret on every server start.
// This invalidates all previously issued JWT tokens, ensuring the user
// must re-authenticate after each dev server restart.
if (process.env.NODE_ENV === "development") {
process.env.JWT_SECRET = crypto.randomBytes(32).toString("hex")
}
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
eslint: { eslint: {
@@ -12,6 +20,7 @@ const nextConfig: NextConfig = {
}, },
], ],
}, },
} }
export default nextConfig 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,
},
},
}
+438 -10
View File
@@ -25,12 +25,13 @@
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.108.2",
"@tanstack/react-table": "^8.20.6", "@tanstack/react-table": "^8.20.6",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"jose": "^6.2.3", "jose": "^6.2.3",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
@@ -41,6 +42,8 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"recharts": "^2.15.0", "recharts": "^2.15.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
@@ -443,6 +446,9 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -459,6 +465,9 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -475,6 +484,9 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -491,6 +503,9 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -507,6 +522,9 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -523,6 +541,9 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -539,6 +560,9 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -561,6 +585,9 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -583,6 +610,9 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -605,6 +635,9 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -627,6 +660,9 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -649,6 +685,9 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -709,6 +748,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -776,13 +816,15 @@
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.0.4", "version": "15.0.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.4.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.4.tgz",
"integrity": "sha512-WNRvtgnRVDD4oM8gbUcRc27IAhaL4eXQ/2ovGbgLnPGUvdyDr8UdXP4Q/IBDdAdojnD2eScryIDirv0YUCjUVw==" "integrity": "sha512-WNRvtgnRVDD4oM8gbUcRc27IAhaL4eXQ/2ovGbgLnPGUvdyDr8UdXP4Q/IBDdAdojnD2eScryIDirv0YUCjUVw==",
"license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "15.0.4", "version": "15.0.4",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.0.4.tgz", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.0.4.tgz",
"integrity": "sha512-rbsF17XGzHtR7SDWzWpavSfum3/UdnF8bAaisnKwP//si3KWPTedVUsflAdjyK1zW3rweBjbALfKcavFneLGvg==", "integrity": "sha512-rbsF17XGzHtR7SDWzWpavSfum3/UdnF8bAaisnKwP//si3KWPTedVUsflAdjyK1zW3rweBjbALfKcavFneLGvg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"fast-glob": "3.3.1" "fast-glob": "3.3.1"
} }
@@ -794,6 +836,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -809,6 +852,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -824,6 +868,10 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -839,6 +887,10 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -854,6 +906,10 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -869,6 +925,10 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -884,6 +944,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -899,6 +960,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -1879,15 +1941,119 @@
"integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==",
"dev": true "dev": true
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
"integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
"integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
"integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
"integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
"integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.2",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/ssr": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.0.tgz",
"integrity": "sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.108.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
"integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
"integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.108.2",
"@supabase/functions-js": "2.108.2",
"@supabase/postgrest-js": "2.108.2",
"@supabase/realtime-js": "2.108.2",
"@supabase/storage-js": "2.108.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@swc/counter": { "node_modules/@swc/counter": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
}, },
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.13", "version": "0.5.13",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz",
"integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@@ -1933,6 +2099,15 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/d3-array": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -2009,7 +2184,6 @@
"version": "20.19.43", "version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -2050,6 +2224,15 @@
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.61.0", "version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
@@ -2608,6 +2791,19 @@
"win32" "win32"
] ]
}, },
"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/acorn": { "node_modules/acorn": {
"version": "8.17.0", "version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@@ -2965,6 +3161,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true "dev": true
}, },
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.37", "version": "2.10.37",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
@@ -3240,6 +3445,7 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"color-convert": "^2.0.1", "color-convert": "^2.0.1",
@@ -3271,6 +3477,7 @@
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"color-name": "^1.0.0", "color-name": "^1.0.0",
@@ -3342,6 +3549,36 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "url": "https://github.com/chalk/supports-color?sponsor=1"
} }
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3544,7 +3781,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
@@ -3606,6 +3842,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true, "optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -3672,7 +3909,8 @@
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==", "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
@@ -3680,6 +3918,58 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true "dev": true
}, },
"node_modules/engine.io": {
"version": "6.6.9",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
"integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"@types/ws": "^8.5.12",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/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/es-abstract": { "node_modules/es-abstract": {
"version": "1.24.2", "version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -3933,6 +4223,7 @@
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.0.4.tgz", "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.0.4.tgz",
"integrity": "sha512-97mLaAhbJKVQYXUBBrenRtEUAA6bNDPxWfaFEd6mEhKfpajP4wJrW4l7BUlHuYWxR8oQa9W014qBJpumpJQwWA==", "integrity": "sha512-97mLaAhbJKVQYXUBBrenRtEUAA6bNDPxWfaFEd6mEhKfpajP4wJrW4l7BUlHuYWxR8oQa9W014qBJpumpJQwWA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "15.0.4", "@next/eslint-plugin-next": "15.0.4",
"@rushstack/eslint-patch": "^1.10.3", "@rushstack/eslint-patch": "^1.10.3",
@@ -4279,6 +4570,7 @@
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3", "@nodelib/fs.walk": "^1.2.3",
@@ -4295,6 +4587,7 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"is-glob": "^4.0.1" "is-glob": "^4.0.1"
}, },
@@ -4735,6 +5028,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4812,6 +5114,7 @@
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT",
"optional": true "optional": true
}, },
"node_modules/is-async-function": { "node_modules/is-async-function": {
@@ -5453,6 +5756,27 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"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/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -5490,8 +5814,7 @@
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
"dev": true
}, },
"node_modules/mz": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
@@ -5542,11 +5865,21 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true "dev": true
}, },
"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/next": { "node_modules/next": {
"version": "15.0.4", "version": "15.0.4",
"resolved": "https://registry.npmjs.org/next/-/next-15.0.4.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.0.4.tgz",
"integrity": "sha512-nuy8FH6M1FG0lktGotamQDCXhh5hZ19Vo0ht1AOIQWrYJLP598TIUagKtvJrfJ5AGwB/WmDqkKaKhMpVifvGPA==", "integrity": "sha512-nuy8FH6M1FG0lktGotamQDCXhh5hZ19Vo0ht1AOIQWrYJLP598TIUagKtvJrfJ5AGwB/WmDqkKaKhMpVifvGPA==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
"license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "15.0.4", "@next/env": "15.0.4",
"@swc/counter": "0.1.3", "@swc/counter": "0.1.3",
@@ -6726,6 +7059,7 @@
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"color": "^4.2.3", "color": "^4.2.3",
@@ -6870,11 +7204,68 @@
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"is-arrayish": "^0.3.1" "is-arrayish": "^0.3.1"
} }
}, },
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.8",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.21.0"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/sonner": { "node_modules/sonner": {
"version": "1.7.4", "version": "1.7.4",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
@@ -7508,8 +7899,7 @@
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
"dev": true
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
"version": "1.12.2", "version": "1.12.2",
@@ -7633,6 +8023,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true "dev": true
}, },
"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"
}
},
"node_modules/vaul": { "node_modules/vaul": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
@@ -7806,6 +8205,35 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+11 -5
View File
@@ -4,12 +4,14 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start", "dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"", "dev:signaling": "node signaling-server.mjs",
"dev:open": "powershell -NoProfile -Command \"Start-Sleep 8; Start-Process 'http://localhost:3001/splash'\"",
"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:next": "next dev -p 3006",
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"", "dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3007,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
"dev:ollama": "powershell -NoProfile -Command \"if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"", "dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
"dev:rust": "cd rust-ai && cargo run", "dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "cd browser-use-service && python main.py", "dev:browser-use": "set FX_PROFILE=C:\\Users\\USER-PC\\AppData\\Roaming\\Mozilla\\Firefox\\Profiles\\h8p11vlj.default-release && cd browser-use-service && python main.py",
"build": "next build", "build": "next build",
"start": "next start -p 3006", "start": "next start -p 3006",
"lint": "eslint" "lint": "eslint"
@@ -32,6 +34,8 @@
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.108.2",
"@tanstack/react-table": "^8.20.6", "@tanstack/react-table": "^8.20.6",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@@ -47,6 +51,8 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"recharts": "^2.15.0", "recharts": "^2.15.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
+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"
+215 -51
View File
@@ -15,6 +15,7 @@ use tokio::sync::Mutex;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use uuid::Uuid; use uuid::Uuid;
use rand::Rng; use rand::Rng;
use chrono::Timelike;
use std::time::Duration; use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -93,6 +94,25 @@ struct LeadStore {
max_size: usize, 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 { impl LeadStore {
fn new(max_size: usize) -> Self { fn new(max_size: usize) -> Self {
Self { leads: Vec::new(), max_size } Self { leads: Vec::new(), max_size }
@@ -188,7 +208,7 @@ fn extract_claims(headers: &HeaderMap, state: &AppState) -> Result<Claims, (Stat
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| { let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string()) (StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
})?; })?;
match claims.role.as_str() { match claims.role.to_lowercase().as_str() {
"sales" | "admin" | "super_admin" => Ok(claims), "sales" | "admin" | "super_admin" => Ok(claims),
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())), _ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
} }
@@ -266,30 +286,64 @@ async fn handle_chat(
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job"); 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") { 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 now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let service_url = "http://localhost:3008/scrape/all".to_string(); let base_url = "http://localhost:3008/scrape/facebook";
if let Ok(resp) = state.http_client use std::fmt::Write;
.post(&service_url) let mut service_url = base_url.to_string();
.timeout(Duration::from_secs(120)) if let Ok(Some((_, path))) = sqlx::query_as::<_, (uuid::Uuid, String)>(
.send() "SELECT id, profile_path FROM facebook_accounts \
.await WHERE is_active = TRUE AND flagged = FALSE \
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
)
.fetch_optional(&state.db)
.await
{ {
if let Ok(leads_data) = resp.json::<Vec<serde_json::Value>>().await { let encoded: String = path.chars().map(|c| match c {
info!("Scraped {} leads from {}", leads_data.len(), service_url); 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' | '~' => c.to_string(),
let mut store = state.leads.lock().await; _ => format!("%{:02X}", c as u8),
for item in &leads_data { }).collect();
store.push(Lead { write!(service_url, "?profile_path={}&force=true", encoded).unwrap();
title: truncate(item["title"].as_str().unwrap_or(""), 120), info!("Calling Python scrape at: {}?profile_path=...&force=true", base_url);
url: item["url"].as_str().unwrap_or("").to_string(), } else {
source: item["source"].as_str().unwrap_or("unknown").to_string(), warn!("No active Facebook account found for on-demand scrape");
found_at: now, }
author: truncate(item["author"].as_str().unwrap_or(""), 60), let req_builder = state.http_client.post(&service_url);
date: truncate(item["date"].as_str().unwrap_or(""), 30),
content: truncate(item["content"].as_str().unwrap_or(""), 300), 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)]);
} }
} }
} else { Err(e) => {
error!("Scraper service unreachable: {}", service_url); error!("Scraper request error: {} - URL: {}", e, base_url);
}
} }
let recent_leads = state.leads.lock().await.recent(604800, 20); let recent_leads = state.leads.lock().await.recent(604800, 20);
@@ -392,7 +446,7 @@ async fn main() {
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); 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 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 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 model = std::env::var("AI_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".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 port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
@@ -411,7 +465,7 @@ async fn main() {
info!("Connected to PostgreSQL"); info!("Connected to PostgreSQL");
let http_client = reqwest::Client::builder() let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(120)) .timeout(Duration::from_secs(300))
.build() .build()
.expect("Failed to build HTTP client"); .expect("Failed to build HTTP client");
@@ -441,7 +495,7 @@ async fn main() {
.route("/ai/jobs", get(handle_jobs)) .route("/ai/jobs", get(handle_jobs))
.route("/health", get(handle_health)) .route("/health", get(handle_health))
.layer(cors) .layer(cors)
.with_state(state); .with_state(state.clone());
let addr = format!("{}:{}", host, port); let addr = format!("{}:{}", host, port);
info!("CRM AI server listening on {}", addr); info!("CRM AI server listening on {}", addr);
@@ -451,10 +505,11 @@ async fn main() {
.expect("Failed to bind address"); .expect("Failed to bind address");
let bg_leads = lead_store.clone(); let bg_leads = lead_store.clone();
let bg_url = "http://localhost:3008/scrape/all".to_string(); let bg_db = state.db.clone();
let bg_url = "http://localhost:3008/scrape/facebook".to_string();
tokio::spawn(async move { tokio::spawn(async move {
let client = match reqwest::Client::builder() let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(120)) .timeout(Duration::from_secs(300))
.build() .build()
{ {
Ok(c) => c, Ok(c) => c,
@@ -463,40 +518,149 @@ async fn main() {
return; return;
} }
}; };
// Initial delay to let user on-demand requests take priority
tokio::time::sleep(Duration::from_secs(300)).await;
loop { 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(); let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
match client.post(&bg_url).send().await {
Ok(resp) => { // Pick next active un-flagged account
if resp.status().is_success() { let account = sqlx::query_as::<_, (uuid::Uuid, String)>(
match resp.json::<Vec<serde_json::Value>>().await { "SELECT id, profile_path FROM facebook_accounts \
Ok(data) => { WHERE is_active = TRUE AND flagged = FALSE \
let mut store = bg_leads.lock().await; ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
for item in &data { )
store.push(Lead { .fetch_optional(&bg_db)
title: truncate(item["title"].as_str().unwrap_or(""), 120), .await;
url: item["url"].as_str().unwrap_or("").to_string(),
source: item["source"].as_str().unwrap_or("unknown").to_string(), match account {
found_at: now, Ok(Some((account_id, profile_path))) => {
author: truncate(item["author"].as_str().unwrap_or(""), 60), match client.post(&bg_url).query(&[("profile_path", &profile_path)]).send().await {
date: truncate(item["date"].as_str().unwrap_or(""), 30), Ok(resp) => {
content: truncate(item["content"].as_str().unwrap_or(""), 300), 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 {
Err(e) => { warn!("Scraper returned status: {}", resp.status());
warn!("Failed to parse scraper JSON: {}", e);
} }
} }
} else { Err(e) => {
warn!("Scraper returned status: {}", resp.status()); warn!("Scraper request failed: {}", e);
}
} }
} }
Ok(None) => {
info!("No active Facebook accounts available — skipping scrape cycle");
}
Err(e) => { Err(e) => {
warn!("Scraper request failed: {}", e); warn!("Failed to query Facebook accounts: {}", e);
} }
} }
let delay = rand::thread_rng().gen_range(120..300);
tokio::time::sleep(Duration::from_secs(delay)).await; let jitter = rand::thread_rng().gen_range(16200..19800); // 4.5h 5.5h
tokio::time::sleep(Duration::from_secs(jitter)).await;
} }
}); });
+125
View File
@@ -0,0 +1,125 @@
param(
[string]$BackupDir = "$PSScriptRoot\..\backups",
[int]$RetentionDays = 30
)
$ErrorActionPreference = "Stop"
$startTime = Get-Date
# Ensure backup directory exists
if (-not (Test-Path $BackupDir)) {
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
}
# Determine database URL from env or .env.local
$dbUrl = $env:DATABASE_URL
if (-not $dbUrl -and (Test-Path "$PSScriptRoot\..\.env.local")) {
$envContent = Get-Content "$PSScriptRoot\..\.env.local" -Raw
if ($envContent -match 'DATABASE_URL=(.+)') {
$dbUrl = $Matches[1].Trim()
}
}
if (-not $dbUrl) {
Write-Error "DATABASE_URL not found. Set it as an environment variable or in .env.local"
exit 1
}
# Parse the database URL
$uri = [System.Uri]$dbUrl
$pgUser = $uri.UserInfo.Split(':')[0]
$pgPass = $uri.UserInfo.Split(':')[1]
$pgHost = $uri.Host
$pgPort = $uri.Port
$pgDb = $uri.AbsolutePath.TrimStart('/')
# Set PGPASSWORD for pg_dump
$env:PGPASSWORD = $pgPass
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$filename = "crm_backup_$timestamp.sql"
$filepath = Join-Path $BackupDir $filename
Write-Output "Starting backup of database '$pgDb' to $filepath"
Write-Output "Database: $pgHost:$pgPort/$pgDb"
try {
# Run pg_dump
$pgDumpPath = if (Get-Command pg_dump -ErrorAction SilentlyContinue) {
"pg_dump"
} else {
"$env:TEMP\pg\pgsql\bin\pg_dump.exe"
}
$output = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --verbose --file $filename 2>&1
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0 -or -not (Test-Path $filename)) {
throw "pg_dump failed with exit code $exitCode"
}
Move-Item -Path $filename -Destination $filepath -Force
$fileInfo = Get-Item $filepath
$fileSize = $fileInfo.Length
# Verify backup by checking if file is valid custom format
$verifyOutput = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --schema-only --file "$filename.verify" 2>&1
$verifyExitCode = $LASTEXITCODE
if (Test-Path "$filename.verify") { Remove-Item "$filename.verify" -Force }
$verificationStatus = if ($verifyExitCode -eq 0) { "verified" } else { "failed" }
# Calculate duration
$endTime = Get-Date
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
# Log the backup
$logQuery = @"
INSERT INTO backup_logs (backup_type, status, file_name, file_size_bytes, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days)
VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationStatus', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays);
"@
# Try to log to database (may fail if db is not reachable for logging, that's OK)
try {
$env:PGPASSWORD = $pgPass
& $env:TEMP\pg\pgsql\bin\psql.exe -h $pgHost -p $pgPort -U $pgUser -d $pgDb -c $logQuery 2>&1 | Out-Null
} catch {
Write-Warning "Could not log backup to database: $_"
}
Write-Output "Backup completed successfully:"
Write-Output " File: $filename"
Write-Output " Size: $([math]::Round($fileSize / 1MB, 2)) MB"
Write-Output " Duration: ${durationSeconds}s"
Write-Output " Status: $verificationStatus"
# Cleanup old backups (beyond retention period)
$cutoff = (Get-Date).AddDays(-$RetentionDays)
$oldBackups = Get-ChildItem $BackupDir -Filter "crm_backup_*.sql" | Where-Object {
$_.CreationTime -lt $cutoff
}
foreach ($old in $oldBackups) {
Remove-Item $old.FullName -Force
Write-Output "Removed old backup: $($old.Name)"
}
} catch {
Write-Error "Backup failed: $_"
# Log failure
$endTime = Get-Date
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
$errorMsg = $_.ToString().Replace("'", "''")
$failQuery = @"
INSERT INTO backup_logs (backup_type, status, file_name, error_message, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days)
VALUES ('full', 'failed', '$filename', '$errorMsg', $exitCode, 'failed', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays);
"@
try {
$env:PGPASSWORD = $pgPass
& $env:TEMP\pg\pgsql\bin\psql.exe -h $pgHost -p $pgPort -U $pgUser -d $pgDb -c $failQuery 2>&1 | Out-Null
} catch {}
exit 1
} finally {
Remove-Item "Env:PGPASSWORD" -ErrorAction SilentlyContinue
}
+221
View File
@@ -0,0 +1,221 @@
import { createServer } from "http"
import { Server } from "socket.io"
import { SignJWT, jwtVerify } from "jose"
import pg from "pg"
const PORT = process.env.SIGNALING_PORT || 3007
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026")
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm"
const pool = new pg.Pool({ connectionString: DATABASE_URL })
const httpServer = createServer()
const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } })
const onlineUsers = new Map()
const rooms = new Map()
io.use((socket, next) => {
const token = socket.handshake.auth?.token
if (token) {
jwtVerify(token, JWT_SECRET)
.then(({ payload }) => {
socket.data.userId = payload.userId
socket.data.role = payload.role
next()
})
.catch(() => {
socket.data.userId = null
socket.data.role = null
next()
})
} else {
socket.data.userId = null
socket.data.role = null
next()
}
})
io.on("connection", (socket) => {
const userId = socket.data.userId
if (userId) {
onlineUsers.set(userId, socket.id)
socket.broadcast.emit("user:online", { userId })
}
if (userId) {
socket.on("user:lookup", async ({ phone }, callback) => {
try {
const result = await pool.query(
`SELECT id, username, first_name, last_name, phone, avatar_url
FROM users
WHERE phone = $1 AND deleted_at IS NULL
LIMIT 1`,
[phone],
)
if (result.rows.length > 0) {
const user = result.rows[0]
callback({
found: true,
user: {
id: user.id,
username: user.username,
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
avatar: user.avatar_url,
online: onlineUsers.has(user.id),
},
})
} else {
callback({ found: false })
}
} catch {
callback({ found: false, error: "Lookup failed" })
}
})
socket.on("call:offer", async ({ to, sdp, callerInfo }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
let info = callerInfo
if (!info) {
try {
const result = await pool.query(
`SELECT id, username, first_name, last_name, avatar_url
FROM users WHERE id = $1`,
[userId],
)
if (result.rows.length > 0) {
const u = result.rows[0]
info = {
id: u.id,
username: u.username,
firstName: u.first_name,
lastName: u.last_name,
avatar: u.avatar_url,
}
}
} catch {}
}
io.to(targetSocketId).emit("call:incoming", {
from: userId,
sdp,
callerInfo: info,
})
} else {
socket.emit("call:user-offline", { userId: to })
}
})
socket.on("call:answer", ({ to, sdp }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
io.to(targetSocketId).emit("call:answered", { from: userId, sdp })
}
})
socket.on("call:ice-candidate", ({ to, candidate }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
io.to(targetSocketId).emit("call:ice-candidate", { from: userId, candidate })
}
})
socket.on("call:end", ({ to }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
io.to(targetSocketId).emit("call:ended", { from: userId })
}
})
socket.on("call:reject", ({ to }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
io.to(targetSocketId).emit("call:rejected", { from: userId })
}
})
socket.on("call:busy", ({ to }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
io.to(targetSocketId).emit("call:busy", { from: userId })
}
})
}
socket.on("room:join", ({ roomId, displayName }) => {
const participantId = userId || `anon-${socket.id}`
const participantLabel = displayName || participantId
if (!rooms.has(roomId)) {
rooms.set(roomId, [])
}
const participants = rooms.get(roomId)
participants.push({ socketId: socket.id, id: participantId, label: participantLabel })
socket.join(roomId)
const list = participants.map(p => ({ id: p.id, label: p.label }))
if (participants.length === 1) {
socket.emit("room:waiting", { roomId, participants: list })
} else if (participants.length === 2) {
io.to(participants[0].socketId).emit("room:initiate-offer", { roomId, participants: list })
io.to(participants[1].socketId).emit("room:participant-joined", { roomId, participants: list })
} else {
socket.emit("room:participant-joined", { roomId, participants: list })
socket.to(roomId).emit("room:participant-joined", { roomId, participants: list })
}
})
socket.on("room:offer", ({ roomId, sdp }) => {
socket.to(roomId).emit("room:offer", { sdp })
})
socket.on("room:answer", ({ roomId, sdp }) => {
socket.to(roomId).emit("room:answer", { sdp })
})
socket.on("room:ice-candidate", ({ roomId, candidate }) => {
socket.to(roomId).emit("room:ice-candidate", { candidate })
})
socket.on("room:leave", ({ roomId }) => {
socket.leave(roomId)
const participants = rooms.get(roomId)
if (participants) {
const idx = participants.findIndex(p => p.socketId === socket.id)
if (idx !== -1) participants.splice(idx, 1)
if (participants.length === 0) {
rooms.delete(roomId)
} else {
const list = participants.map(p => ({ id: p.id, label: p.label }))
io.to(roomId).emit("room:participant-left", { roomId, participants: list })
}
}
})
socket.on("disconnect", () => {
for (const [roomId, participants] of rooms) {
const idx = participants.findIndex(p => p.socketId === socket.id)
if (idx !== -1) {
participants.splice(idx, 1)
if (participants.length === 0) {
rooms.delete(roomId)
} else {
const list = participants.map(p => ({ id: p.id, label: p.label }))
io.to(roomId).emit("room:participant-left", { roomId, participants: list })
}
}
}
if (userId) {
onlineUsers.delete(userId)
socket.broadcast.emit("user:offline", { userId })
}
})
})
httpServer.listen(PORT, () => {
console.log(`[signaling] server running on port ${PORT}`)
})
+453
View File
@@ -0,0 +1,453 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Loading CoastIT CRM</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a1a;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: 'Segoe UI', system-ui, sans-serif;
color: #fff;
overflow: hidden;
}
/* Robot */
.robot {
position: relative;
width: 120px;
height: 160px;
margin-bottom: 40px;
animation: float 2s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
.robot-head {
width: 70px;
height: 60px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border-radius: 16px 16px 8px 8px;
margin: 0 auto 4px;
position: relative;
animation: bob 0.6s ease-in-out infinite alternate;
box-shadow: 0 0 30px rgba(99,102,241,0.3);
}
@keyframes bob {
0% { transform: rotate(-3deg); }
100% { transform: rotate(3deg); }
}
.robot-eye {
position: absolute;
width: 10px;
height: 10px;
background: #22d3ee;
border-radius: 50%;
top: 20px;
box-shadow: 0 0 8px #22d3ee;
animation: blink 3s infinite;
}
.robot-eye.left { left: 16px; }
.robot-eye.right { right: 16px; }
@keyframes blink {
0%, 94%, 100% { transform: scaleY(1); }
97% { transform: scaleY(0.1); }
}
.robot-antenna {
width: 4px;
height: 12px;
background: #8b5cf6;
margin: -10px auto 0;
border-radius: 2px;
animation: antenna-pulse 1s ease-in-out infinite;
}
.robot-antenna::after {
content: '';
display: block;
width: 8px;
height: 8px;
background: #22d3ee;
border-radius: 50%;
margin: -2px auto 0;
box-shadow: 0 0 10px #22d3ee;
}
@keyframes antenna-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.robot-body {
width: 60px;
height: 50px;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
border-radius: 6px;
margin: 0 auto;
position: relative;
box-shadow: 0 0 20px rgba(99,102,241,0.2);
}
.robot-arm {
position: absolute;
width: 12px;
height: 36px;
background: #6366f1;
border-radius: 6px;
top: 6px;
animation: swing 0.8s ease-in-out infinite alternate;
}
.robot-arm.left { left: -16px; transform-origin: top center; }
.robot-arm.right { right: -16px; transform-origin: top center; animation-delay: 0.4s; }
@keyframes swing {
0% { transform: rotate(-25deg); }
100% { transform: rotate(25deg); }
}
.robot-leg {
position: absolute;
width: 14px;
height: 30px;
background: #4f46e5;
border-radius: 4px;
bottom: -28px;
animation: step 0.6s ease-in-out infinite alternate;
}
.robot-leg.left { left: 8px; }
.robot-leg.right { right: 8px; animation-delay: 0.3s; }
@keyframes step {
0% { transform: translateY(0) rotate(-8deg); }
100% { transform: translateY(-4px) rotate(8deg); }
}
/* Status indicators */
.services {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 32px;
min-width: 300px;
}
.service {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: rgba(255,255,255,0.04);
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.06);
transition: all 0.3s;
}
.service.ready {
border-color: rgba(34,211,238,0.3);
background: rgba(34,211,238,0.06);
}
.service.failed {
border-color: rgba(239,68,68,0.3);
background: rgba(239,68,68,0.06);
}
.service-name {
flex: 1;
font-size: 14px;
font-weight: 500;
opacity: 0.7;
}
.service.ready .service-name {
opacity: 1;
color: #22d3ee;
}
.service.failed .service-name {
opacity: 1;
color: #ef4444;
}
.status-dots {
display: flex;
gap: 5px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255,255,255,0.1);
transition: all 0.4s;
}
.service.ready .status-dot {
background: #22d3ee;
box-shadow: 0 0 6px rgba(34,211,238,0.5);
}
.service.failed .status-dot {
background: #ef4444;
box-shadow: 0 0 6px rgba(239,68,68,0.5);
}
.status-dot:nth-child(2) { transition-delay: 0.1s; }
.status-dot:nth-child(3) { transition-delay: 0.2s; }
.status-text {
font-size: 11px;
opacity: 0.4;
min-width: 50px;
text-align: right;
}
.service.ready .status-text {
opacity: 0.8;
color: #22d3ee;
}
.service.failed .status-text {
opacity: 0.8;
color: #ef4444;
}
/* Loading text */
.loading-text {
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, #6366f1, #22d3ee);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: pulse-text 2s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* Launch gear (inline, below everything) */
.launch-overlay {
display: none;
flex-direction: column;
align-items: center;
gap: 16px;
margin-top: 8px;
}
.launch-overlay.active {
display: flex;
}
.launch-gear {
width: 80px;
height: 80px;
border: 4px solid #6366f1;
border-top-color: #22d3ee;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.launch-text {
font-size: 20px;
font-weight: 700;
color: #22d3ee;
animation: scale-in 0.5s ease-out;
}
@keyframes scale-in {
0% { transform: scale(0.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
/* Error state */
.loading-text.error {
background: linear-gradient(135deg, #ef4444, #f97316);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.error-msg {
display: none;
font-size: 13px;
color: #ef4444;
margin-top: 4px;
text-align: center;
}
.error-msg.active {
display: block;
}
.retry-btn {
display: none;
margin-top: 12px;
padding: 8px 24px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border: none;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.retry-btn:hover {
opacity: 0.85;
}
.retry-btn.active {
display: inline-block;
}
</style>
</head>
<body>
<!-- Robot -->
<div class="robot">
<div class="robot-antenna"></div>
<div class="robot-head">
<div class="robot-eye left"></div>
<div class="robot-eye right"></div>
</div>
<div class="robot-body">
<div class="robot-arm left"></div>
<div class="robot-arm right"></div>
<div class="robot-leg left"></div>
<div class="robot-leg right"></div>
</div>
</div>
<!-- Services -->
<div class="services">
<div class="service" id="svc-ai">
<span class="service-name">AI Server</span>
<div class="status-dots">
<div class="status-dot"></div>
<div class="status-dot"></div>
<div class="status-dot"></div>
</div>
<span class="status-text" id="status-ai">waiting...</span>
</div>
<div class="service" id="svc-scraper">
<span class="service-name">Scraper</span>
<div class="status-dots">
<div class="status-dot"></div>
<div class="status-dot"></div>
<div class="status-dot"></div>
</div>
<span class="status-text" id="status-scraper">waiting...</span>
</div>
<div class="service" id="svc-frontend">
<span class="service-name">Frontend</span>
<div class="status-dots">
<div class="status-dot"></div>
<div class="status-dot"></div>
<div class="status-dot"></div>
</div>
<span class="status-text" id="status-frontend">waiting...</span>
</div>
</div>
<div class="loading-text" id="loading-text">Loading...</div>
<!-- Launch gear (inline, below everything) -->
<div class="launch-overlay" id="launch-overlay">
<div class="launch-gear"></div>
<div class="launch-text">🚀 Launching gear!</div>
</div>
<div class="error-msg" id="error-msg"></div>
<button class="retry-btn" id="retry-btn" onclick="location.reload()">Try Again</button>
<script>
const MAX_ATTEMPTS = 30;
let attempts = 0;
const CHECKS = [
{ id: 'ai', key: 'ai', ready: false, failed: false },
{ id: 'scraper', key: 'scraper', ready: false, failed: false },
{ id: 'frontend',key: 'frontend', ready: false, failed: false },
];
const MSGS = { ai: 'AI Ready', scraper: 'Scraper Ready', frontend: 'Frontend Ready' };
const NAMES = { ai: 'AI Server', scraper: 'Scraper', frontend: 'Frontend' };
async function checkAllServices() {
try {
const res = await fetch('/status', { cache: 'no-store' });
const data = await res.json();
for (const svc of CHECKS) {
svc.ready = data[svc.key] === true;
}
} catch {
for (const svc of CHECKS) svc.ready = false;
}
}
function updateUI() {
let allReady = true;
let anyFailed = false;
const failedNames = [];
for (const svc of CHECKS) {
const el = document.getElementById('svc-' + svc.id);
const statusText = document.getElementById('status-' + svc.id);
el.classList.remove('ready', 'failed');
if (svc.ready) {
el.classList.add('ready');
statusText.textContent = MSGS[svc.id];
} else if (svc.failed) {
el.classList.add('failed');
statusText.textContent = 'Failed';
anyFailed = true;
allReady = false;
failedNames.push(NAMES[svc.id]);
} else {
statusText.textContent = 'waiting...';
allReady = false;
}
}
const loadingText = document.getElementById('loading-text');
const errorMsg = document.getElementById('error-msg');
const retryBtn = document.getElementById('retry-btn');
if (anyFailed) {
loadingText.className = 'loading-text error';
loadingText.textContent = 'Something went wrong';
errorMsg.textContent = failedNames.join(', ') + ' failed to start. Check your terminal for details.';
errorMsg.classList.add('active');
retryBtn.classList.add('active');
document.getElementById('launch-overlay').classList.remove('active');
} else if (allReady) {
loadingText.className = 'loading-text';
loadingText.textContent = 'All systems ready!';
errorMsg.classList.remove('active');
retryBtn.classList.remove('active');
document.getElementById('launch-overlay').classList.add('active');
setTimeout(() => { window.location.href = 'http://localhost:3006/login'; }, 5000);
} else {
loadingText.className = 'loading-text';
const ready = CHECKS.filter(s => s.ready).length;
loadingText.textContent = `Loading... (${ready}/${CHECKS.length})`;
}
}
async function poll() {
attempts++;
if (attempts > MAX_ATTEMPTS) {
for (const svc of CHECKS) {
if (!svc.ready) svc.failed = true;
}
updateUI();
return;
}
await checkAllServices();
updateUI();
if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed)) {
setTimeout(poll, 2000);
}
}
poll();
</script>
</body>
</html>
+6 -5
View File
@@ -15,7 +15,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { import {
Search, Send, Phone, Video, MoreHorizontal, Paperclip, Search, Send, Phone, MoreHorizontal, Paperclip,
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck, Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
CornerDownRight, Forward, Pencil, Download, CornerDownRight, Forward, Pencil, Download,
} from "lucide-react" } from "lucide-react"
@@ -28,6 +28,7 @@ import { Textarea } from "@/components/ui/textarea"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { useUser } from "@/providers/user-provider" import { useUser } from "@/providers/user-provider"
import { toast } from "sonner" import { toast } from "sonner"
import VoiceCallModal from "@/components/chats/voice-call-modal"
import data from "@emoji-mart/data" import data from "@emoji-mart/data"
import Picker from "@emoji-mart/react" import Picker from "@emoji-mart/react"
@@ -124,6 +125,7 @@ export default function ChatsPage() {
const [searchResults, setSearchResults] = useState<any[]>([]) const [searchResults, setSearchResults] = useState<any[]>([])
const [searchingUsers, setSearchingUsers] = useState(false) const [searchingUsers, setSearchingUsers] = useState(false)
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map()) const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null) const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -642,12 +644,9 @@ export default function ChatsPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
<Phone className="h-4 w-4" /> <Phone className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
<Video className="h-4 w-4" />
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"> <Button variant="ghost" size="icon" className="h-8 w-8">
@@ -995,6 +994,8 @@ export default function ChatsPage() {
</ScrollArea> </ScrollArea>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
</div> </div>
) )
} }
+11 -4
View File
@@ -62,8 +62,15 @@ export default function DashboardPage() {
: [] : []
return ( return (
<div className="space-y-6"> <div className="space-y-6 relative">
<PageHeader title="Dashboard" description="Overview of your sales pipeline"> {/* Daily Bugle watermark */}
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#e62020] dark:text-[#FF6666] tracking-wider">
DAILY BUGLE
</span>
</div>
<PageHeader title={<span style={{fontFamily:"'Bangers',cursive"}}>Dashboard</span>} description="Overview of your sales pipeline">
<Select value={period} onValueChange={setPeriod}> <Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="h-9 w-[160px]"> <SelectTrigger className="h-9 w-[160px]">
<ListFilter className="mr-2 h-4 w-4" /> <ListFilter className="mr-2 h-4 w-4" />
@@ -78,7 +85,7 @@ export default function DashboardPage() {
</Select> </Select>
</PageHeader> </PageHeader>
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Pipeline Overview</p> <p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{stats {stats
@@ -89,7 +96,7 @@ export default function DashboardPage() {
} }
</div> </div>
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Analytics</p> <p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<LeadStatusChart data={stats?.statusDistribution ?? []} /> <LeadStatusChart data={stats?.statusDistribution ?? []} />
+11 -12
View File
@@ -1,14 +1,12 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { chatWithAI } from "@/lib/ai" import { chatWithAI } from "@/lib/ai"
import { getSessionUser } from "@/lib/auth"
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (!["sales", "admin", "super_admin"].includes(user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
} }
const { message } = await request.json() const { message } = await request.json()
@@ -16,15 +14,16 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Message is required" }, { status: 400 }) return NextResponse.json({ error: "Message is required" }, { status: 400 })
} }
// Forward the JWT from the session cookie to the Rust backend const sessionCookie = request.cookies.get("session")
const sessionCookie = request.cookies.get("session")?.value const jwtToken = sessionCookie?.value
if (!sessionCookie) return NextResponse.json({ error: "No session" }, { status: 401 }) if (!jwtToken) {
return NextResponse.json({ error: "No session token" }, { status: 401 })
const response = await chatWithAI(message, sessionCookie) }
const response = await chatWithAI(message, jwtToken)
return NextResponse.json({ response }) return NextResponse.json({ response })
} catch (error) { } catch (error: any) {
console.error("AI chat error:", error) console.error("AI chat error:", error)
return NextResponse.json({ error: "AI service unavailable" }, { status: 503 }) return NextResponse.json({ error: error.message || "AI service error" }, { status: 500 })
} }
} }
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
export async function GET() {
const cookieStore = await cookies()
const token = cookieStore.get("session")?.value
if (!token) {
return NextResponse.json({ error: "No session" }, { status: 401 })
}
return NextResponse.json({ token })
}
+2
View File
@@ -9,6 +9,7 @@ import {
resetFailedAttempts, resetFailedAttempts,
isAccountLocked, isAccountLocked,
createSession, createSession,
setSessionContext,
} from "@/lib/auth" } from "@/lib/auth"
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -106,6 +107,7 @@ export async function POST(request: NextRequest) {
) )
await createSession(dbUser.id, dbUser.role_name) await createSession(dbUser.id, dbUser.role_name)
await setSessionContext(dbUser.id, ipAddress)
const user = mapDbUserToSessionUser(dbUser) const user = mapDbUserToSessionUser(dbUser)
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server"
import {
getSessionUser,
decryptPassword,
setSessionContext,
} from "@/lib/auth"
import { query } from "@/lib/db"
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only SUPER_ADMIN can recover passwords." }, { status: 403 })
}
const { userId } = await request.json()
if (!userId) {
return NextResponse.json({ error: "userId is required." }, { status: 400 })
}
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"127.0.0.1"
await setSessionContext(sessionUser.id, ipAddress)
const result = await query(
`SELECT id, username, email, first_name, last_name, password_encrypted
FROM users WHERE id = $1 AND deleted_at IS NULL`,
[userId]
)
const user = result.rows[0]
if (!user) {
return NextResponse.json({ error: "User not found." }, { status: 404 })
}
if (!user.password_encrypted) {
return NextResponse.json({ error: "No encrypted password stored for this user." }, { status: 404 })
}
const plaintextPassword = await decryptPassword(user.password_encrypted)
if (!plaintextPassword) {
return NextResponse.json({ error: "Failed to decrypt password. Master key may have changed." }, { status: 500 })
}
return NextResponse.json({
user: {
id: user.id,
username: user.username,
email: user.email,
name: `${user.first_name} ${user.last_name}`,
},
password: plaintextPassword,
}, { status: 200 })
} catch (error) {
console.error("Password recovery error:", error)
return NextResponse.json({ error: "Recovery service unavailable." }, { status: 503 })
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser, setSessionContext } from "@/lib/auth"
export async function PATCH(request: NextRequest, { params: routeParams }: { params: Promise<{ id: string }> }) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden. Only admins can update bug reports." }, { status: 403 })
}
const { id } = await routeParams
const { status, assigned_to, resolution_notes } = await request.json()
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"127.0.0.1"
await setSessionContext(sessionUser.id, ipAddress)
const validStatuses = ["open", "in_progress", "resolved", "closed"]
if (status && !validStatuses.includes(status)) {
return NextResponse.json({ error: "Invalid status." }, { status: 400 })
}
const existing = await query("SELECT id, status FROM bug_reports WHERE id = $1", [id])
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Bug report not found." }, { status: 404 })
}
const updates: string[] = []
const values: unknown[] = []
let paramIndex = 1
if (status !== undefined) {
updates.push(`status = $${paramIndex++}`)
values.push(status)
}
if (assigned_to !== undefined) {
updates.push(`assigned_to = $${paramIndex++}`)
values.push(assigned_to === "null" ? null : assigned_to)
}
if (resolution_notes !== undefined) {
updates.push(`resolution_notes = $${paramIndex++}`)
values.push(resolution_notes)
}
if (updates.length === 0) {
return NextResponse.json({ error: "No fields to update." }, { status: 400 })
}
updates.push(`updated_at = NOW()`)
values.push(id)
await query(
`UPDATE bug_reports SET ${updates.join(", ")} WHERE id = $${paramIndex}`,
values
)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
console.error("Error updating bug report:", error)
return NextResponse.json({ error: "Failed to update bug report." }, { status: 500 })
}
}
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
const { title, description, severity, page_url, screenshot_url } = await request.json()
if (!title || !description) {
return NextResponse.json({ error: "Title and description are required." }, { status: 400 })
}
const validSeverities = ["low", "medium", "high", "critical"]
if (severity && !validSeverities.includes(severity)) {
return NextResponse.json({ error: "Invalid severity." }, { status: 400 })
}
const result = await query(
`INSERT INTO bug_reports (reported_by, title, description, severity, page_url, screenshot_url)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at`,
[sessionUser.id, title, description, severity || "medium", page_url || null, screenshot_url || null]
)
return NextResponse.json({
success: true,
id: result.rows[0].id,
created_at: result.rows[0].created_at,
}, { status: 201 })
} catch (error) {
console.error("Error creating bug report:", error)
return NextResponse.json({ error: "Failed to submit bug report." }, { status: 500 })
}
}
export async function GET(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden. Only admins can view bug reports." }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const severity = searchParams.get("severity")
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
let sql = `SELECT br.id, br.title, br.description, br.severity, br.page_url,
br.screenshot_url, br.status, br.resolution_notes,
br.created_at, br.updated_at,
reporter.id AS reporter_id,
reporter.first_name AS reporter_first_name,
reporter.last_name AS reporter_last_name,
reporter.email AS reporter_email,
assignee.id AS assignee_id,
assignee.first_name AS assignee_first_name,
assignee.last_name AS assignee_last_name
FROM bug_reports br
JOIN users reporter ON reporter.id = br.reported_by
LEFT JOIN users assignee ON assignee.id = br.assigned_to`
const params: unknown[] = []
const conditions: string[] = []
if (status) {
conditions.push(`br.status = $${params.length + 1}`)
params.push(status)
}
if (severity) {
conditions.push(`br.severity = $${params.length + 1}`)
params.push(severity)
}
if (conditions.length > 0) {
sql += " WHERE " + conditions.join(" AND ")
}
sql += " ORDER BY br.created_at DESC LIMIT $" + (params.length + 1) + " OFFSET $" + (params.length + 2)
params.push(limit, offset)
const result = await query(sql, params)
const reports = result.rows.map((row: any) => ({
id: row.id,
title: row.title,
description: row.description,
severity: row.severity,
page_url: row.page_url,
screenshot_url: row.screenshot_url,
status: row.status,
resolution_notes: row.resolution_notes,
created_at: row.created_at,
updated_at: row.updated_at,
reporter: {
id: row.reporter_id,
name: `${row.reporter_first_name} ${row.reporter_last_name}`,
email: row.reporter_email,
},
assignee: row.assignee_id ? {
id: row.assignee_id,
name: `${row.assignee_first_name} ${row.assignee_last_name}`,
} : null,
}))
return NextResponse.json({ reports }, { status: 200 })
} catch (error) {
console.error("Error fetching bug reports:", error)
return NextResponse.json({ error: "Failed to fetch bug reports." }, { status: 500 })
}
}
@@ -116,13 +116,28 @@ export async function POST(
) )
const msg = result.rows[0] const msg = result.rows[0]
const senderName = `${user.firstName} ${user.lastName}`
const otherResult = await query(
`SELECT user_id FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`,
[id, user.id],
)
for (const row of otherResult.rows) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, 'chat_message', 'New Message', $2, '/chats', $3, 'conversation')`,
[row.user_id, `${senderName} sent a message`, id],
)
}
return NextResponse.json({ return NextResponse.json({
message: { message: {
id: msg.id, id: msg.id,
conversationId: id, conversationId: id,
senderId: user.id, senderId: user.id,
senderName: `${user.firstName} ${user.lastName}`, senderName,
senderAvatar: user.avatar, senderAvatar: user.avatar,
content: content.trim(), content: content.trim(),
timestamp: formatTime(new Date(msg.created_at)), timestamp: formatTime(new Date(msg.created_at)),
@@ -19,6 +19,12 @@ export async function POST(
[id, user.id], [id, user.id],
) )
await query(
`UPDATE notifications SET is_read = TRUE
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
[user.id, id],
)
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error("Mark read error:", error) console.error("Mark read error:", error)
+8 -5
View File
@@ -13,10 +13,11 @@ export async function GET() {
c.id, c.id,
c.updated_at, c.updated_at,
cp_me.last_read_at, cp_me.last_read_at,
u.id AS other_user_id, u.id AS other_user_id,
u.first_name || ' ' || u.last_name AS other_user_name, u.first_name || ' ' || u.last_name AS other_user_name,
u.email AS other_user_email, u.email AS other_user_email,
u.avatar_url AS other_user_avatar_url, u.phone AS other_user_phone,
u.avatar_url AS other_user_avatar_url,
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message, (SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time, (SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread (SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
@@ -39,6 +40,7 @@ export async function GET() {
id: row.other_user_id, id: row.other_user_id,
name: row.other_user_name, name: row.other_user_name,
email: row.other_user_email, email: row.other_user_email,
phone: row.other_user_phone || "",
avatar: avatarSvgUrl(row.other_user_name), avatar: avatarSvgUrl(row.other_user_name),
}, },
lastMessage: row.last_message || "", lastMessage: row.last_message || "",
@@ -90,7 +92,7 @@ export async function POST(request: NextRequest) {
) )
const otherUser = await query( const otherUser = await query(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url `SELECT id, first_name || ' ' || last_name AS name, email, phone, avatar_url
FROM users WHERE id = $1`, FROM users WHERE id = $1`,
[userId], [userId],
) )
@@ -105,6 +107,7 @@ export async function POST(request: NextRequest) {
id: other.id, id: other.id,
name: other.name, name: other.name,
email: other.email, email: other.email,
phone: other.phone || "",
avatar: avatarSvgUrl(other.name), avatar: avatarSvgUrl(other.name),
}, },
lastMessage: "", lastMessage: "",
+9 -4
View File
@@ -48,7 +48,7 @@ function stageToStatus(name: string): string {
} }
} }
async function fetchLeadsInRange(start: Date, end: Date) { async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmin?: boolean) {
const result = await query( const result = await query(
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone, `SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
l.notes, l.assigned_to, l.score, l.notes, l.assigned_to, l.score,
@@ -59,8 +59,11 @@ async function fetchLeadsInRange(start: Date, end: Date) {
LEFT JOIN users u ON u.id = l.assigned_to LEFT JOIN users u ON u.id = l.assigned_to
WHERE l.deleted_at IS NULL WHERE l.deleted_at IS NULL
AND l.created_at >= $1 AND l.created_at <= $2 AND l.created_at >= $1 AND l.created_at <= $2
${isAdmin ? "" : "AND l.assigned_to = $3"}
ORDER BY l.created_at DESC`, ORDER BY l.created_at DESC`,
[start.toISOString(), end.toISOString()] isAdmin
? [start.toISOString(), end.toISOString()]
: [start.toISOString(), end.toISOString(), userId]
) )
return result.rows.map((r: any) => ({ return result.rows.map((r: any) => ({
...r, ...r,
@@ -118,6 +121,8 @@ export async function GET(request: NextRequest) {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const isAdmin = user.role === "admin" || user.role === "super_admin"
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const period = searchParams.get("period") || "6months" const period = searchParams.get("period") || "6months"
const yearParam = searchParams.get("year") const yearParam = searchParams.get("year")
@@ -134,8 +139,8 @@ export async function GET(request: NextRequest) {
} }
const [currentLeads, prevLeads] = await Promise.all([ const [currentLeads, prevLeads] = await Promise.all([
fetchLeadsInRange(start, end), fetchLeadsInRange(start, end, user.id, isAdmin),
fetchLeadsInRange(prevRange.start, prevRange.end), fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
]) ])
const currentCounts = countStatuses(currentLeads) const currentCounts = countStatuses(currentLeads)
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import crypto from "crypto"
export async function POST(request: NextRequest) {
try {
const { phone, token: clientToken } = await request.json()
if (!phone) {
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
}
const token = clientToken || crypto.randomBytes(24).toString("hex")
await query(
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
[token, phone],
)
const origin = request.headers.get("origin") || "http://localhost:3000"
const inviteUrl = `${origin}/join/${token}`
return NextResponse.json({ token, inviteUrl })
} catch {
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
}
}
+19
View File
@@ -3,12 +3,28 @@ import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db" import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar" import { avatarSvgUrl } from "@/lib/avatar"
async function checkLeadAccess(leadId: string, userId: string): Promise<boolean> {
const result = await query(
`SELECT 1 FROM leads WHERE id = $1 AND deleted_at IS NULL
AND (assigned_to = $2 OR EXISTS (
SELECT 1 FROM user_roles ur JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = $2 AND r.name IN ('ADMIN', 'SUPER_ADMIN')
))`,
[leadId, userId]
)
return result.rows.length > 0
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try { try {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params const { id } = await params
if (!await checkLeadAccess(id, user.id)) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
const { content } = await request.json() const { content } = await request.json()
if (!content?.trim()) { if (!content?.trim()) {
return NextResponse.json({ error: "Content is required" }, { status: 400 }) return NextResponse.json({ error: "Content is required" }, { status: 400 })
@@ -33,6 +49,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params const { id } = await params
if (!await checkLeadAccess(id, user.id)) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get("limit") || "50", 10) const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10) const offset = parseInt(searchParams.get("offset") || "0", 10)
+5
View File
@@ -111,6 +111,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) } if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) } if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
if (body.assignedUserId !== undefined) { if (body.assignedUserId !== undefined) {
const isAdmin = user.role === "admin" || user.role === "super_admin"
if (!isAdmin) {
// non-admin cannot reassign
return NextResponse.json({ error: "Only admins can reassign leads" }, { status: 403 })
}
fields.push(`assigned_to = $${idx++}`) fields.push(`assigned_to = $${idx++}`)
values.push(body.assignedUserId === "none" ? null : body.assignedUserId) values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
} }
+10 -1
View File
@@ -123,6 +123,15 @@ export async function POST(request: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json() const body = await request.json()
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Non-admin users can only assign leads to themselves; admin/super_admin can assign to anyone
let assignedUserId = body.assignedUserId
if (!isAdmin) {
assignedUserId = user.id
} else if (assignedUserId === "none" || !assignedUserId) {
assignedUserId = null
}
const stageResult = await query( const stageResult = await query(
"SELECT id FROM lead_stages WHERE name = $1", "SELECT id FROM lead_stages WHERE name = $1",
@@ -142,7 +151,7 @@ export async function POST(request: NextRequest) {
body.description || null, body.description || null,
body.source || null, body.source || null,
stageId, stageId,
body.assignedUserId === "none" ? null : body.assignedUserId, assignedUserId,
] ]
) )
+5 -2
View File
@@ -8,7 +8,7 @@ export async function GET() {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query( const result = await query(
`SELECT id, type, title, description, link, is_read, created_at `SELECT id, type, title, description, link, is_read, created_at, context_id, context_type
FROM notifications FROM notifications
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -24,6 +24,8 @@ export async function GET() {
link: r.link, link: r.link,
read: r.is_read, read: r.is_read,
timestamp: r.created_at, timestamp: r.created_at,
contextId: r.context_id,
contextType: r.context_type,
})) }))
const unreadResult = await query( const unreadResult = await query(
@@ -47,7 +49,8 @@ export async function POST(request: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { type, title, description, link, userId } = await request.json() const { type, title, description, link, userId } = await request.json()
const targetUserId = userId || user.id const isAdmin = user.role === "admin" || user.role === "super_admin"
const targetUserId = (userId && isAdmin) ? userId : user.id
const result = await query( const result = await query(
`INSERT INTO notifications (user_id, type, title, description, link) `INSERT INTO notifications (user_id, type, title, description, link)
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
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 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const { id } = await params
const body = await request.json()
const fields: string[] = []
const values: any[] = []
let idx = 1
if (body.isActive !== undefined) {
fields.push(`is_active = $${idx++}`)
values.push(body.isActive)
}
if (body.label !== undefined) {
fields.push(`label = $${idx++}`)
values.push(body.label.trim())
}
if (body.profilePath !== undefined) {
fields.push(`profile_path = $${idx++}, cookie_file = $${idx}`)
values.push(body.profilePath.trim())
values.push(`${body.profilePath.replace(/\\+$/, '')}\\cookies.sqlite`)
idx++
}
if (body.unflag === true) {
fields.push(`flagged = FALSE, flagged_at = NULL, flagged_reason = NULL, consecutive_failures = 0`)
}
if (fields.length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
fields.push(`updated_at = NOW()`)
values.push(id)
await query(
`UPDATE facebook_accounts SET ${fields.join(", ")} WHERE id = $${idx}`,
values
)
const updated = await query(
`SELECT id, label, profile_path, is_active, flagged, flagged_reason,
consecutive_failures, updated_at
FROM facebook_accounts WHERE id = $1`,
[id]
)
return NextResponse.json(updated.rows[0] || { success: true })
} catch (error) {
console.error("Facebook accounts PATCH error:", error)
return NextResponse.json({ error: "Failed to update Facebook account" }, { status: 500 })
}
}
@@ -0,0 +1,66 @@
import { NextRequest, 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 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const accounts = await query(
`SELECT fa.id, fa.label, fa.profile_path, fa.is_active,
fa.last_scrape_at, fa.last_success_at, fa.last_error_at,
fa.last_error_message, fa.consecutive_failures,
fa.flagged, fa.flagged_at, fa.flagged_reason,
fa.created_at, fa.updated_at,
COALESCE(sl.leads_found, 0) AS last_leads_found,
sl.success AS last_success,
sl.detected_flag AS last_detected_flag
FROM facebook_accounts fa
LEFT JOIN LATERAL (
SELECT leads_found, success, detected_flag
FROM facebook_scrape_logs
WHERE account_id = fa.id
ORDER BY created_at DESC
LIMIT 1
) sl ON TRUE
ORDER BY fa.created_at ASC`
)
return NextResponse.json(accounts.rows)
} catch (error) {
console.error("Facebook accounts GET error:", error)
return NextResponse.json({ error: "Failed to load Facebook accounts" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const { label, profilePath } = await request.json()
if (!label?.trim() || !profilePath?.trim()) {
return NextResponse.json({ error: "Label and profile path are required" }, { status: 400 })
}
const cookieFile = `${profilePath.replace(/\\+$/, '')}\\cookies.sqlite`
const result = await query(
`INSERT INTO facebook_accounts (label, profile_path, cookie_file)
VALUES ($1, $2, $3)
RETURNING id, label, profile_path, is_active, created_at`,
[label.trim(), profilePath.trim(), cookieFile]
)
return NextResponse.json(result.rows[0], { status: 201 })
} catch (error) {
console.error("Facebook accounts POST error:", error)
return NextResponse.json({ error: "Failed to create Facebook account" }, { status: 500 })
}
}
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (user.role !== "admin" && user.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const accountId = searchParams.get("accountId")
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
let sql = `SELECT sl.id, sl.account_id, fa.label AS account_label,
sl.started_at, sl.completed_at, sl.success,
sl.leads_found, sl.error_message, sl.detected_flag,
sl.created_at
FROM facebook_scrape_logs sl
JOIN facebook_accounts fa ON fa.id = sl.account_id`
const params: any[] = []
let paramIdx = 1
if (accountId) {
sql += ` WHERE sl.account_id = $${paramIdx++}`
params.push(accountId)
}
sql += ` ORDER BY sl.created_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx++}`
params.push(limit, offset)
const result = await query(sql, params)
return NextResponse.json(result.rows)
} catch (error) {
console.error("Facebook scrape logs GET error:", error)
return NextResponse.json({ error: "Failed to load scrape logs" }, { status: 500 })
}
}
@@ -0,0 +1,41 @@
import { NextRequest, 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 preferences->>'website_theme' AS website_theme FROM users WHERE id = $1`,
[user.id],
)
const websiteTheme = result.rows[0]?.website_theme || "spidey"
return NextResponse.json({ websiteTheme })
} catch (error) {
console.error("Website theme GET error:", error)
return NextResponse.json({ error: "Failed to load website theme" }, { status: 500 })
}
}
export async function PUT(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const theme = body.websiteTheme || "spidey"
await query(
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
[user.id, JSON.stringify({ website_theme: theme })],
)
return NextResponse.json({ success: true, websiteTheme: theme })
} catch (error) {
console.error("Website theme PUT error:", error)
return NextResponse.json({ error: "Failed to save website theme" }, { status: 500 })
}
}
+3
View File
@@ -8,6 +8,9 @@ let prevTime = Date.now()
export async function GET() { export async function GET() {
const sessionUser = await getSessionUser() const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const now = Date.now() const now = Date.now()
const elapsed = now - prevTime const elapsed = now - prevTime
+15
View File
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db" import { query } from "@/lib/db"
const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"]
const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const user = await getSessionUser() const user = await getSessionUser()
@@ -12,6 +15,18 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 }) return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
} }
const allowed = ALLOWED_PREFIXES.some((p) => avatar.startsWith(p))
if (!allowed) {
return NextResponse.json({ error: "Avatar must be a PNG, JPEG, or GIF data URL" }, { status: 400 })
}
// Approximate decoded size: base64 is ~4/3 of original
const base64Data = avatar.split(",")[1] || ""
const estimatedBytes = Math.round(base64Data.length * 0.75)
if (estimatedBytes > MAX_AVATAR_BYTES) {
return NextResponse.json({ error: "Avatar exceeds 2MB size limit" }, { status: 400 })
}
await query( await query(
`UPDATE users SET avatar_url = $1 WHERE id = $2`, `UPDATE users SET avatar_url = $1 WHERE id = $2`,
[avatar, user.id], [avatar, user.id],
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
const phone = request.nextUrl.searchParams.get("phone")
if (!phone) {
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
}
try {
const result = await query(
`SELECT id, username, first_name, last_name, phone, avatar_url
FROM users
WHERE phone = $1 AND deleted_at IS NULL
LIMIT 1`,
[phone],
)
if (result.rows.length === 0) {
return NextResponse.json({ found: false })
}
const user = result.rows[0]
return NextResponse.json({
found: true,
user: {
id: user.id,
username: user.username,
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
avatar: user.avatar_url,
},
})
} catch {
return NextResponse.json({ error: "Lookup failed" }, { status: 500 })
}
}
+10 -4
View File
@@ -1,12 +1,15 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db" import { query } from "@/lib/db"
import { hashPassword, getSessionUser } from "@/lib/auth" import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
import { avatarSvgUrl } from "@/lib/avatar" import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const sessionUser = await getSessionUser() const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get("limit") || "50", 10) const limit = parseInt(searchParams.get("limit") || "50", 10)
@@ -60,17 +63,20 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 }) return NextResponse.json({ error: "Invalid role" }, { status: 400 })
} }
await setSessionContext(sessionUser.id)
const nameParts = name.trim().split(/\s+/) const nameParts = name.trim().split(/\s+/)
const firstName = nameParts[0] const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(" ") || firstName const lastName = nameParts.slice(1).join(" ") || firstName
const username = email.split("@")[0] const username = email.split("@")[0]
const passwordHash = await hashPassword(password) const passwordHash = await hashPassword(password)
const passwordEncrypted = await encryptPassword(password)
const result = await query( const result = await query(
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by) `INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`, RETURNING id`,
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id] [username.toLowerCase(), email.toLowerCase(), passwordHash, passwordEncrypted, firstName, lastName, active ?? true, sessionUser.id]
) )
const roleId = ( const roleId = (
+3
View File
@@ -7,6 +7,9 @@ export async function GET(request: NextRequest) {
try { try {
const currentUser = await getSessionUser() const currentUser = await getSessionUser()
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (currentUser.role !== "admin" && currentUser.role !== "super_admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const q = request.nextUrl.searchParams.get("q") || "" const q = request.nextUrl.searchParams.get("q") || ""
+226
View File
@@ -0,0 +1,226 @@
"use client"
import { useState, useEffect, use, useCallback } from "react"
import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, Users, Loader2 } from "lucide-react"
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
export default function CallRoomPage({ params }: { params: Promise<{ roomId: string }> }) {
const { roomId } = use(params)
const {
callState,
isMuted,
isSpeakerOn,
callDuration,
error,
isReady,
participants,
joinRoom,
endCall,
toggleMute,
toggleSpeaker,
formatDuration,
} = useWebRTCCall({ anonymous: true })
const [displayName, setDisplayName] = useState("")
const [joined, setJoined] = useState(false)
const [micError, setMicError] = useState<string | null>(null)
const [connectionTimeout, setConnectionTimeout] = useState(false)
useEffect(() => {
if (isReady) return
const timer = setTimeout(() => {
if (!isReady) setConnectionTimeout(true)
}, 10000)
return () => clearTimeout(timer)
}, [isReady])
const handleJoin = useCallback(async () => {
if (!displayName.trim()) return
setMicError(null)
try {
await navigator.mediaDevices.getUserMedia({ audio: true })
joinRoom(roomId, displayName.trim() || "Anonymous")
setJoined(true)
} catch {
setMicError("Microphone access is required to join the call. Please allow microphone access in your browser settings.")
}
}, [displayName, roomId, joinRoom])
const handleEnd = useCallback(() => {
endCall()
}, [endCall])
if (!joined) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
{connectionTimeout ? (
<>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">This call is no longer available.</h1>
</>
) : !isReady ? (
<>
<div className="flex flex-col items-center gap-4 py-6">
<Loader2 className="h-8 w-8 animate-spin text-[#CC0000]" />
<p className="text-[#888888] text-sm">Connecting to call...</p>
</div>
</>
) : (
<>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">Join Call</h1>
{micError && (
<p className="text-[#CC0000] text-xs mb-4">{micError}</p>
)}
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
placeholder="Enter your name"
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
/>
<button
onClick={handleJoin}
disabled={!displayName.trim()}
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
>
<Phone className="h-4 w-4" />
Join Call
</button>
</>
)}
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
{callState === "calling" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Calling</h1>
<p className="text-[#888888] text-sm animate-pulse">Connecting to call room...</p>
<div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" />
</button>
</div>
</div>
)}
{callState === "waiting" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Waiting for participant</h1>
<p className="text-[#888888] text-sm animate-pulse">Share the call link to invite someone...</p>
{participants.length > 0 && (
<div className="mt-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
<Users className="h-3 w-3" />
Participants ({participants.length})
</div>
{participants.map((p) => (
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
))}
</div>
)}
<div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" />
</button>
</div>
</div>
)}
{callState === "participant_joined" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Participant joined</h1>
{participants.length > 0 && (
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
<Users className="h-3 w-3" />
Participants ({participants.length})
</div>
{participants.map((p) => (
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
))}
</div>
)}
<p className="text-[#888888] text-sm animate-pulse">Connecting...</p>
<div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" />
</button>
</div>
</div>
)}
{callState === "connecting" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Connecting</h1>
<p className="text-[#888888] text-sm animate-pulse">Establishing secure connection...</p>
<div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" />
</button>
</div>
</div>
)}
{callState === "connected" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-1">Connected</h1>
{participants.length > 0 && (
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
<Users className="h-3 w-3" />
Participants ({participants.length})
</div>
{participants.map((p) => (
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
))}
</div>
)}
<div className="text-[#CC0000] font-mono text-lg mb-6">
{formatDuration(callDuration)}
</div>
<div className="flex justify-center gap-4">
<button
onClick={toggleSpeaker}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
>
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
</button>
<button
onClick={toggleMute}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
>
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
</button>
<button
onClick={handleEnd}
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
>
<PhoneOff className="h-5 w-5" />
</button>
</div>
</div>
)}
{(callState === "ended" || callState === "idle") && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Call ended</h1>
{callDuration > 0 && (
<p className="text-[#888888] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
)}
</div>
)}
{error && (
<p className="text-[#CC0000] text-xs mt-4">{error}</p>
)}
</div>
</div>
)
}
+329 -12
View File
@@ -1,7 +1,15 @@
@import url('https://fonts.googleapis.com/css2?family=Bangers&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@800&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@keyframes loading {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
:root { :root {
--background: 210 40% 98%; --background: 210 40% 98%;
--foreground: 222.2 84% 4.9%; --foreground: 222.2 84% 4.9%;
@@ -9,7 +17,7 @@
--card-foreground: 222.2 84% 4.9%; --card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%; --primary: 0 100% 40%;
--primary-foreground: 210 40% 98%; --primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%; --secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 222.2 47.4% 11.2%;
@@ -22,15 +30,16 @@
--border: 214.3 31.8% 91.4%; --border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%; --ring: 221.2 83.2% 53.3%;
--sidebar: 0 0% 100%; --sidebar: 216 12% 92%;
--sidebar-foreground: 222.2 84% 4.9%; --sidebar-foreground: 0 0% 20%;
--sidebar-primary: 221.2 83.2% 53.3%; --sidebar-primary: 0 91.2% 59.8%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 40% 96.1%; --sidebar-accent: 218 16% 87%;
--sidebar-accent-foreground: 222.2 84% 4.9%; --sidebar-accent-foreground: 0 0% 20%;
--sidebar-border: 214.3 31.8% 91.4%; --sidebar-border: 220 11% 84%;
--sidebar-ring: 221.2 83.2% 53.3%; --sidebar-ring: 0 76.3% 48%;
--radius: 0.5rem; --radius: 0.5rem;
--theme-primary: hsl(var(--primary));
} }
.dark { .dark {
@@ -40,7 +49,7 @@
--card-foreground: 210 40% 98%; --card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%; --popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%; --popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%; --primary: 0 100% 53%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%; --secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 210 40% 98%;
@@ -52,15 +61,15 @@
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%; --border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%; --ring: 0 100% 53%;
--sidebar: 222.2 84% 4.9%; --sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%; --sidebar-foreground: 210 40% 98%;
--sidebar-primary: 217.2 91.2% 59.8%; --sidebar-primary: 0 100% 53%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%; --sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%; --sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%; --sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 224.3 76.3% 48%; --sidebar-ring: 0 100% 53%;
} }
.ocean { .ocean {
@@ -315,6 +324,314 @@
--sidebar-ring: 351 85% 52%; --sidebar-ring: 351 85% 52%;
} }
.ocean {
--primary: 187 75% 42%;
--primary-foreground: 210 40% 98%;
--ring: 187 75% 42%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 187 75% 42%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 187 30% 94%;
--sidebar-accent-foreground: 187 50% 20%;
--sidebar-border: 187 20% 88%;
--sidebar-ring: 187 75% 42%;
}
.dark.ocean {
--primary: 187 75% 50%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 187 75% 50%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 187 75% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 187 75% 50%;
}
.forest {
--primary: 142 76% 36%;
--primary-foreground: 210 40% 98%;
--ring: 142 76% 36%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 142 76% 36%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 142 30% 94%;
--sidebar-accent-foreground: 142 50% 20%;
--sidebar-border: 142 20% 88%;
--sidebar-ring: 142 76% 36%;
}
.dark.forest {
--primary: 142 76% 44%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 142 76% 44%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 142 76% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 142 76% 44%;
}
.sunset {
--primary: 24 95% 53%;
--primary-foreground: 210 40% 98%;
--ring: 24 95% 53%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 24 95% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 24 30% 94%;
--sidebar-accent-foreground: 24 50% 20%;
--sidebar-border: 24 20% 88%;
--sidebar-ring: 24 95% 53%;
}
.dark.sunset {
--primary: 24 95% 58%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 24 95% 58%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 24 95% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 24 95% 58%;
}
.midnight {
--primary: 230 75% 55%;
--primary-foreground: 210 40% 98%;
--ring: 230 75% 55%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 230 75% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 230 30% 94%;
--sidebar-accent-foreground: 230 50% 20%;
--sidebar-border: 230 20% 88%;
--sidebar-ring: 230 75% 55%;
}
.dark.midnight {
--primary: 230 75% 62%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 230 75% 62%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 230 75% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 230 75% 62%;
}
.rose {
--primary: 346 77% 50%;
--primary-foreground: 210 40% 98%;
--ring: 346 77% 50%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 346 77% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 346 30% 94%;
--sidebar-accent-foreground: 346 50% 20%;
--sidebar-border: 346 20% 88%;
--sidebar-ring: 346 77% 50%;
}
.dark.rose {
--primary: 346 77% 58%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 346 77% 58%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 346 77% 60%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 346 77% 58%;
}
.amber {
--primary: 38 92% 50%;
--primary-foreground: 210 40% 98%;
--ring: 38 92% 50%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 38 92% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 38 30% 94%;
--sidebar-accent-foreground: 38 50% 20%;
--sidebar-border: 38 20% 88%;
--sidebar-ring: 38 92% 50%;
}
.dark.amber {
--primary: 38 92% 56%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 38 92% 56%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 38 92% 60%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 38 92% 56%;
}
.violet {
--primary: 262 83% 58%;
--primary-foreground: 210 40% 98%;
--ring: 262 83% 58%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 262 83% 58%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 262 30% 94%;
--sidebar-accent-foreground: 262 50% 20%;
--sidebar-border: 262 20% 88%;
--sidebar-ring: 262 83% 58%;
}
.dark.violet {
--primary: 262 83% 65%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 262 83% 65%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 262 83% 68%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 262 83% 65%;
}
.slate {
--primary: 215 20% 45%;
--primary-foreground: 210 40% 98%;
--ring: 215 20% 45%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 215 20% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 215 20% 94%;
--sidebar-accent-foreground: 215 50% 20%;
--sidebar-border: 215 20% 88%;
--sidebar-ring: 215 20% 45%;
}
.dark.slate {
--primary: 215 20% 60%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 215 20% 60%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 215 20% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 215 20% 60%;
}
.ruby {
--primary: 351 85% 45%;
--primary-foreground: 210 40% 98%;
--ring: 351 85% 45%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 351 85% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 351 30% 94%;
--sidebar-accent-foreground: 351 50% 20%;
--sidebar-border: 351 20% 88%;
--sidebar-ring: 351 85% 45%;
}
.dark.ruby {
--primary: 351 85% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 351 85% 52%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 351 85% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 351 85% 52%;
}
.bc-logo {
font-family: 'Raleway', sans-serif;
font-weight: 800;
font-size: 18px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: hsl(var(--sidebar-foreground));
padding: 8px 16px;
position: relative;
display: inline-block;
}
.bc-logo .accent {
color: var(--theme-primary, #3b91f7);
}
.bc-logo::before {
content: '';
position: absolute;
top: 0;
left: 0;
border-top: 1.5px solid var(--theme-primary, #3b91f7);
border-left: 1.5px solid var(--theme-primary, #3b91f7);
width: 0;
height: 0;
animation: drawTL 3s ease-in-out infinite;
}
.bc-logo::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
border-bottom: 1.5px solid var(--theme-primary, #3b91f7);
border-right: 1.5px solid var(--theme-primary, #3b91f7);
width: 0;
height: 0;
animation: drawBR 3s ease-in-out infinite;
}
@keyframes drawTL {
0%, 5% { width: 0; height: 0; }
30% { width: 100%; height: 0; }
55% { width: 100%; height: 100%; }
85% { width: 100%; height: 100%; }
100% { width: 0; height: 0; }
}
@keyframes drawBR {
0%, 5% { width: 0; height: 0; }
30% { width: 100%; height: 0; }
55% { width: 100%; height: 100%; }
85% { width: 100%; height: 100%; }
100% { width: 0; height: 0; }
}
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;
+69
View File
@@ -0,0 +1,69 @@
import { query } from "@/lib/db"
import { redirect } from "next/navigation"
interface Props {
params: Promise<{ token: string }>
}
function ErrorPage({ message }: { message: string }) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
{message}
</h1>
</div>
</div>
)
}
export default async function JoinPage({ params }: Props) {
const { token } = await params
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (UUID_REGEX.test(token)) {
redirect(`/call/${token}`)
}
const result = await query(
`SELECT phone, created_at, expires_at FROM invites WHERE token = $1 LIMIT 1`,
[token],
)
if (result.rows.length === 0) {
return <ErrorPage message="This call link is invalid." />
}
const invite = result.rows[0]
if (new Date(invite.expires_at) <= new Date()) {
return <ErrorPage message="This call has expired." />
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
You&apos;re Invited!
</h1>
<p className="text-[#888888] text-sm mb-6">
Someone wants to connect with you on our platform.
</p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
<p className="text-xs text-[#888888] mb-1">Contact Number</p>
<p className="text-sm font-medium text-[#111111] dark:text-white">{invite.phone}</p>
</div>
<p className="text-xs text-[#888888] mb-6">
Create an account to start making free voice calls to this person and others on the platform.
</p>
<a
href="/register"
className="block w-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
>
Create Account
</a>
</div>
</div>
)
}
+5 -2
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next" import type { Metadata, Viewport } from "next"
import { Inter } from "next/font/google" import { Inter } from "next/font/google"
import { ThemeProvider } from "@/providers/theme-provider" import { ThemeProvider } from "@/providers/theme-provider"
import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import "./globals.css" import "./globals.css"
@@ -31,8 +32,10 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} min-h-screen antialiased`}> <body className={`${inter.variable} min-h-screen antialiased`}>
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange> <ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
{children} <WebsiteThemeProvider>
<Toaster /> {children}
<Toaster />
</WebsiteThemeProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
+9 -14
View File
@@ -1,8 +1,7 @@
"use client" "use client"
import { useState, useEffect, useRef } from "react" import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation" import { useSearchParams } from "next/navigation"
import { COMPANY_NAME } from "@/lib/constants"
import { Eye, EyeOff, Loader2 } from "lucide-react" import { Eye, EyeOff, Loader2 } from "lucide-react"
const waves = [ const waves = [
@@ -14,7 +13,8 @@ const waves = [
] ]
export default function LoginPage() { export default function LoginPage() {
const router = useRouter() const searchParams = useSearchParams()
const redirectTo = searchParams.get("redirect") || "/dashboard"
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
@@ -28,11 +28,11 @@ export default function LoginPage() {
const testimonials = [ const testimonials = [
{ {
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.", text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
author: "Marcus Johnson, Sales Lead at Coast IT", author: "Marcus Johnson, Sales Lead at Black Cipher",
}, },
{ {
text: "When you're not sure, flip a coin, because when the coin is in the air, you realize which option you're actually hoping for.", text: "When you're not sure, flip a coin, because when the coin is in the air, you realize which option you're actually hoping for.",
author: "Dillen van der Merwe, Madman of Coast IT", author: "Dillen van der Merwe, Madman of Black Cipher",
}, },
] ]
@@ -215,7 +215,7 @@ export default function LoginPage() {
}) })
if (res.ok) { if (res.ok) {
router.push("/dashboard") window.location.href = redirectTo
} else { } else {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
setError(data.error || "Invalid email or password.") setError(data.error || "Invalid email or password.")
@@ -230,16 +230,11 @@ export default function LoginPage() {
return ( return (
<div className="flex min-h-screen bg-[#0a0a0f]"> <div className="flex min-h-screen bg-[#0a0a0f]">
<div className="left-panel flex flex-1 flex-col p-12"> <div className="left-panel flex flex-1 flex-col p-12">
<div className="relative z-10">
<img
src="/logo/CompanyLogo.png"
alt={COMPANY_NAME}
className="h-44 w-auto object-contain"
/>
</div>
<div className="relative z-10 flex-1 flex items-center justify-center"> <div className="relative z-10 flex-1 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="bc-logo mb-8" style={{"--theme-primary":"#1BB0CE"}}>
Black <span className="accent">Cipher</span>
</div>
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]"> <h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
Your Agency&apos;s{" "} Your Agency&apos;s{" "}
<span className="growth-word">Growth</span>{" "} <span className="growth-word">Growth</span>{" "}
+111 -33
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useRef, useEffect, Fragment } from "react" import { useState, useRef, useEffect, Fragment } from "react"
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react" import { Send, Bot, User, RefreshCw, AlertCircle, Check, Terminal } from "lucide-react"
function linkifyText(text: string) { function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/ const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
@@ -24,9 +24,26 @@ export function AIChat() {
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null) const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const checkServer = async () => {
try {
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "ping" }),
})
if (res.status !== 503) {
setBootState("ready")
} else {
setTimeout(checkServer, 2000)
}
} catch {
setTimeout(checkServer, 2000)
}
}
useEffect(() => { useEffect(() => {
fetch("/api/ai/jobs") fetch("/api/ai/jobs")
.then((r) => r.json()) .then((r) => r.json())
@@ -56,29 +73,13 @@ export function AIChat() {
}, },
]) ])
}) })
checkServer()
}, []) }, [])
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages]) }, [messages])
const checkOllama = async () => {
try {
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "__ping__" }),
})
setOllamaStatus(res.status !== 503)
} catch {
setOllamaStatus(false)
}
}
useEffect(() => { checkOllama() }, [])
const sendMessage = async () => { const sendMessage = async () => {
const msg = input.trim() const msg = input.trim()
if (!msg || loading) return if (!msg || loading) return
@@ -96,8 +97,8 @@ export function AIChat() {
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json().catch(() => ({}))
throw new Error(data.error || "Failed to get response") throw new Error(data.error || `Error ${res.status}`)
} }
const data = await res.json() const data = await res.json()
@@ -121,17 +122,86 @@ export function AIChat() {
} }
} }
const bootOverlay = (
<div className="flex-1 flex items-center justify-center">
<style>{`
@keyframes walk {
0%, 100% { transform: translateX(0) translateY(0); }
25% { transform: translateX(12px) translateY(-4px); }
50% { transform: translateX(24px) translateY(0); }
75% { transform: translateX(12px) translateY(-4px); }
}
@keyframes legLeft {
0%, 100% { transform: rotate(-10deg); }
50% { transform: rotate(10deg); }
}
@keyframes legRight {
0%, 100% { transform: rotate(10deg); }
50% { transform: rotate(-10deg); }
}
@keyframes armLeft {
0%, 100% { transform: rotate(15deg); }
50% { transform: rotate(-15deg); }
}
@keyframes armRight {
0%, 100% { transform: rotate(-15deg); }
50% { transform: rotate(15deg); }
}
@keyframes blink {
0%, 45%, 55%, 100% { height: 4px; }
50% { height: 1px; }
}
.robot-walk { animation: walk 0.6s ease-in-out infinite; }
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; }
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; }
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; }
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; }
.robot-eye { animation: blink 2s ease-in-out infinite; }
`}</style>
<div className="flex flex-col items-center gap-4">
<p className="text-sm text-[#6a6a75]">Servers booting...</p>
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center overflow-hidden">
<div className="robot-walk relative">
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
<rect x="10" y="2" width="20" height="16" rx="3" fill="#1BB0CE" opacity="0.9"/>
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#1BB0CE" opacity="0.7"/>
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#1BB0CE" opacity="0.7"/>
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0d1117"/>
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#1BB0CE" opacity="0.8"/>
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#1BB0CE" opacity="0.8"/>
</svg>
</div>
</div>
</div>
</div>
)
const readyOverlay = (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center">
<Check className="h-6 w-6 text-green-400" />
</div>
<div className="text-center space-y-1">
<p className="text-xs text-[#6a6a75]">Try these commands:</p>
<div className="flex gap-2">
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> lists</code>
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> leads</code>
</div>
</div>
</div>
</div>
)
if (bootState === "booting") return bootOverlay
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{ollamaStatus === false && ( {bootState === "ready" && readyOverlay}
<div className="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs">
<AlertCircle className="h-3.5 w-3.5 flex-none" />
<span className="flex-1">Ollama not responding. Start it with <code className="bg-amber-500/20 px-1 rounded">ollama serve</code></span>
<button type="button" onClick={checkOllama} className="hover:text-amber-300">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin"> <div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
{messages.map((msg, i) => ( {messages.map((msg, i) => (
@@ -163,7 +233,10 @@ export function AIChat() {
<Bot className="h-4 w-4 text-[#1BB0CE]" /> <Bot className="h-4 w-4 text-[#1BB0CE]" />
</div> </div>
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]"> <div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" /> <svg className="h-4 w-4 animate-spin text-[#1BB0CE]" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
</svg>
</div> </div>
</div> </div>
)} )}
@@ -192,10 +265,15 @@ export function AIChat() {
disabled={loading || !input.trim()} disabled={loading || !input.trim()}
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors" className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
> >
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />} {loading ? (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
</svg>
) : <Send className="h-4 w-4" />}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) )
} }
+350
View File
@@ -0,0 +1,350 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { Phone, X, Copy, MessageSquare } from "lucide-react"
import { getSupabase } from "@/lib/supabase"
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
interface Contact {
id: string
name: string
phone: string
avatar_url: string | null
}
interface VoiceCallModalProps {
open: boolean
onClose: () => void
}
function formatPhoneForWhatsApp(phone: string): string {
const digits = phone.replace(/[^0-9]/g, "")
if (!digits) return ""
if (digits.startsWith("27")) return digits
if (digits.startsWith("0")) return "27" + digits.slice(1)
return digits
}
export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
const [contacts, setContacts] = useState<Contact[]>([])
const [contactsLoading, setContactsLoading] = useState(false)
const [contactsError, setContactsError] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [phoneNumber, setPhoneNumber] = useState("")
const [phoneError, setPhoneError] = useState(false)
const [callLink, setCallLink] = useState<string | null>(null)
const [shareSent, setShareSent] = useState(false)
const [shareError, setShareError] = useState<string | null>(null)
const [targetPhone, setTargetPhone] = useState<string>("")
const [showWaUrl, setShowWaUrl] = useState<string | null>(null)
const { createRoom } = useWebRTCCall()
const handleCall = useCallback((phone?: string) => {
setShareSent(false)
setShareError(null)
const { roomId, link } = createRoom()
setCallLink(link)
setTargetPhone(phone || "")
console.log("[call] room created, roomId:", roomId)
console.log("[call] join link:", link)
console.log("[call] target phone:", phone || "none")
}, [createRoom])
const copyLink = useCallback(async () => {
if (!callLink) return
try {
await navigator.clipboard.writeText(callLink)
setShareSent(true)
setShareError(null)
console.log("[share] link copied")
} catch {
setShareError("Failed to copy URL")
console.error("[share] copy failed")
}
}, [callLink])
const sendWhatsApp = useCallback(() => {
if (!callLink) return
const originalPhone = targetPhone
const formattedPhone = formatPhoneForWhatsApp(originalPhone)
const generatedCallLink = callLink
console.log("[wa] original phone:", originalPhone)
console.log("[wa] formatted phone:", formattedPhone)
console.log("[wa] call link:", generatedCallLink)
if (!formattedPhone) {
setShareError("Invalid phone number.")
console.error("[wa] invalid phone number")
return
}
const msg = encodeURIComponent(`Hi! Join me on this link so we can start our call:\n\n${generatedCallLink}`)
const waUrl = `https://wa.me/${formattedPhone}?text=${msg}`
console.log("[wa] WhatsApp URL:", waUrl)
setShowWaUrl(waUrl)
const w = window.open(waUrl, "_blank")
if (!w || w.closed) {
setShareError("Could not open WhatsApp. Please check your browser allows popups.")
console.error("[wa] failed: window.open returned null or closed")
} else {
setShareSent(true)
setShareError(null)
console.log("[wa] success: WhatsApp opened")
}
}, [callLink, targetPhone])
useEffect(() => {
if (!open) {
setSearchQuery("")
setPhoneNumber("")
setPhoneError(false)
setCallLink(null)
setShareSent(false)
setShareError(null)
setTargetPhone("")
setShowWaUrl(null)
}
}, [open])
const FALLBACK_CONTACTS: Contact[] = [
{ id: "fallback-dillen", name: "Dillen", phone: "0799158142", avatar_url: null },
{ id: "fallback-ewan", name: "Ewan", phone: "0845172665", avatar_url: null },
{ id: "fallback-caitlin", name: "Caitlin", phone: "0612729281", avatar_url: null },
]
useEffect(() => {
if (!open) return
const fetchContacts = async () => {
setContactsLoading(true)
setContactsError(false)
try {
const sb = getSupabase()
const { data, error } = await sb
.from("contacts")
.select("id, name, phone, avatar_url")
.order("name", { ascending: true })
if (error) throw error
setContacts([...(data || []), ...FALLBACK_CONTACTS])
} catch {
setContacts(FALLBACK_CONTACTS)
} finally {
setContactsLoading(false)
}
}
fetchContacts()
}, [open])
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose()
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [open, onClose])
const filteredContacts = contacts.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.phone.toLowerCase().includes(searchQuery.toLowerCase()),
)
if (!open) return null
return (
<div
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
{callLink ? (
shareSent ? (
<>
<p className="text-sm text-[#00AA00] font-semibold text-center mb-3">Call link copied.</p>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Waiting for participant</h2>
<p className="text-[#888888] text-sm mt-1 mb-4">
The recipient must receive and open the call link.
</p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
{callLink}
</div>
<div className="flex gap-2">
<button
onClick={copyLink}
className="flex-1 flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
>
<Copy className="h-4 w-4" />
Copy URL
</button>
<button
onClick={() => { setShareSent(false); setShareError(null) }}
className="flex-1 flex items-center justify-center gap-2 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
>
<MessageSquare className="h-4 w-4" />
WhatsApp
</button>
</div>
<a
href={callLink}
target="_blank"
rel="noopener noreferrer"
className="block w-full mt-4 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
>
Open Call
</a>
</>
) : (
<>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Choose how to notify this person</h2>
<p className="text-[#888888] text-sm mt-1 mb-4">
Select a method to send the call link
</p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
{callLink}
</div>
{showWaUrl && (
<div className="bg-[#1A1A1A] dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#00AA00] font-mono">
{showWaUrl}
</div>
)}
<div className="flex flex-col gap-2 mb-4">
<button
onClick={sendWhatsApp}
className="flex items-center justify-center gap-2 bg-[#25D366] hover:bg-[#1da851] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
>
<MessageSquare className="h-4 w-4" />
WhatsApp
</button>
<button
onClick={copyLink}
className="flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
>
<Copy className="h-4 w-4" />
Copy URL
</button>
</div>
{shareError && (
<p className="text-[#CC0000] text-xs text-center">{shareError}</p>
)}
</>
)
) : (
<>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Start a Call</h2>
<p className="text-[#888888] text-sm mt-1 mb-5">Search contacts or dial a number</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
CONTACTS
</p>
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search contacts..."
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
/>
<div className="max-h-[200px] overflow-y-auto space-y-1 mb-4">
{contactsLoading && (
<p className="text-[#AAAAAA] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
)}
{contactsError && (
<p className="text-[#CC0000] text-sm text-center py-4">Could not load contacts</p>
)}
{!contactsLoading && !contactsError && filteredContacts.length === 0 && (
<p className="text-[#AAAAAA] text-sm text-center py-4">No contacts found</p>
)}
{!contactsLoading && !contactsError && filteredContacts.map((contact) => (
<div
key={contact.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#F5F5F5] dark:hover:bg-[#1A1A1A]"
>
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FFF0F0] dark:bg-[#CC0000]/15 text-[#CC0000] dark:text-[#FF4444]">
{contact.avatar_url ? (
<img
src={contact.avatar_url}
alt={contact.name}
className="w-full h-full rounded-full object-cover"
/>
) : (
contact.name.charAt(0).toUpperCase()
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#111111] dark:text-white truncate">{contact.name}</p>
<p className="text-xs text-[#888888] dark:[#666666] truncate">{contact.phone}</p>
</div>
<button
type="button"
onClick={() => handleCall(contact.phone)}
className="shrink-0 text-[#CC0000] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
>
<Phone className="h-4 w-4" />
</button>
</div>
))}
</div>
<div className="flex items-center gap-3 my-4">
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
<span className="text-xs text-[#AAAAAA] dark:text-[#555555]">OR</span>
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
</div>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
DIAL A NUMBER
</p>
<input
type="tel"
value={phoneNumber}
onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }}
placeholder="+27 000 000 0000"
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
/>
{phoneError && (
<p className="text-[#CC0000] text-xs mt-1">Please enter a phone number</p>
)}
<button
onClick={() => {
const trimmed = phoneNumber.trim()
if (!trimmed) {
setPhoneError(true)
return
}
setPhoneError(false)
handleCall(trimmed)
}}
className="w-full mt-3 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
>
<Phone className="h-4 w-4" />
Call Now
</button>
</>
)}
</div>
</div>
)
}
+15 -2
View File
@@ -68,11 +68,24 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
transition={{ duration: 0.3, delay: 0.2 }} transition={{ duration: 0.3, delay: 0.2 }}
className="h-full" className="h-full"
> >
<Card className="h-full"> <Card className="h-full relative overflow-hidden">
{/* Spider watermark */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.05]">
<svg width="220" height="220" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="90" y1="0" x2="90" y2="180" stroke="#CC0000" strokeWidth="1" />
<line x1="0" y1="90" x2="180" y2="90" stroke="#CC0000" strokeWidth="1" />
<line x1="30" y1="30" x2="150" y2="150" stroke="#0033CC" strokeWidth="1" />
<line x1="150" y1="30" x2="30" y2="150" stroke="#0033CC" strokeWidth="1" />
<circle cx="90" cy="90" r="40" stroke="#CC0000" strokeWidth="1" fill="none" />
<circle cx="90" cy="90" r="60" stroke="#0033CC" strokeWidth="0.8" fill="none" strokeDasharray="4 4" />
<circle cx="90" cy="90" r="80" stroke="#CC0000" strokeWidth="0.5" fill="none" strokeDasharray="2 6" />
</svg>
</div>
<CardHeader> <CardHeader>
<CardTitle>Lead Status</CardTitle> <CardTitle>Lead Status</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-1 flex-col"> <CardContent className="flex flex-1 flex-col relative z-10">
<div className="flex flex-1 flex-col items-center justify-center"> <div className="flex flex-1 flex-col items-center justify-center">
{/* Donut */} {/* Donut */}
<svg width="100%" height="100%" viewBox="0 0 320 320" className="max-h-full overflow-visible" style={{ maxWidth: "280px" }}> <svg width="100%" height="100%" viewBox="0 0 320 320" className="max-h-full overflow-visible" style={{ maxWidth: "280px" }}>
@@ -16,8 +16,8 @@ interface LeadsPerMonthChartProps {
data: IntervalData[] data: IntervalData[]
} }
const NEW_LEADS = "#0d9488" const NEW_LEADS = "#CC0000"
const CLOSED = "#c9a96e" const CLOSED = "#0033CC"
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) { export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
const [year, setYear] = useState(new Date().getFullYear()) const [year, setYear] = useState(new Date().getFullYear())
@@ -153,11 +153,11 @@ export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProp
> >
<defs> <defs>
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0d9488" /> <stop offset="0%" stopColor="#FF1111" />
<stop offset="100%" stopColor={NEW_LEADS} /> <stop offset="100%" stopColor={NEW_LEADS} />
</linearGradient> </linearGradient>
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#e8d5a3" /> <stop offset="0%" stopColor="#1144FF" />
<stop offset="100%" stopColor={CLOSED} /> <stop offset="100%" stopColor={CLOSED} />
</linearGradient> </linearGradient>
<filter id="shadowNew"> <filter id="shadowNew">
+11 -14
View File
@@ -1,20 +1,17 @@
"use client" "use client"
import { Card, CardContent } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
export function StatCardSkeleton() { export function StatCardSkeleton() {
return ( return (
<Card className="h-full"> <div className="col-span-full flex flex-col items-center justify-center py-20">
<CardContent className="p-6 flex flex-col"> <p className="text-[#CC0000] dark:text-[#FF1111] text-5xl font-['Bangers',cursive] animate-pulse">
<div className="flex items-center justify-between"> THWIP!
<div className="space-y-2"> </p>
<Skeleton className="h-4 w-24" /> <p className="text-[#444444] dark:text-[#AAAAAA] text-sm mt-3">
<Skeleton className="h-8 w-16" /> Loading your data...
</div> </p>
<Skeleton className="h-12 w-12 rounded-xl" /> <div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
</div> <div className="w-full h-full rounded-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] animate-[loading_1.5s_ease-in-out_infinite]" />
</CardContent> </div>
</Card> </div>
) )
} }
+51 -13
View File
@@ -19,12 +19,12 @@ interface StatCardProps {
} }
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = { const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
"Total Leads": { bg: "bg-cyan-500/10", text: "text-cyan-600 dark:text-cyan-400", glow: "via-[rgba(6,182,212,0.25)]", accent: "#06b6d4" }, "Total Leads": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
"Open Leads": { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400", glow: "via-[rgba(59,130,246,0.25)]", accent: "#3b82f6" }, "Open Leads": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
"Contacted": { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", glow: "via-[rgba(245,158,11,0.25)]", accent: "#f59e0b" }, "Contacted": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
"Pending": { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", glow: "via-[rgba(139,92,246,0.25)]", accent: "#8b5cf6" }, "Pending": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
"Closed": { bg: "bg-yellow-500/10", text: "text-yellow-600 dark:text-yellow-400", glow: "via-[rgba(234,179,8,0.25)]", accent: "#eab308" }, "Closed": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
"Conversion Rate": { bg: "bg-rose-500/10", text: "text-rose-600 dark:text-rose-400", glow: "via-[rgba(244,63,94,0.25)]", accent: "#f43f5e" }, "Conversion Rate": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
} }
function computeGoal(max: number): number { function computeGoal(max: number): number {
@@ -107,6 +107,10 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
const { path: sparkPath, area: sparkArea, goal, gridLines } = buildSparklineSvg() const { path: sparkPath, area: sparkArea, goal, gridLines } = buildSparklineSvg()
const sparkColor = color.accent const sparkColor = color.accent
const isRed = index % 2 === 0
const stripColor = isRed ? "from-[#CC0000] via-[#FF1111] to-[#CC0000]" : "from-[#0033CC] via-[#1144FF] to-[#0033CC]"
const stripGlow = isRed ? "shadow-[#CC0000]/20" : "shadow-[#0033CC]/20"
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@@ -114,16 +118,50 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
transition={{ duration: 0.3, delay: index * 0.05 }} transition={{ duration: 0.3, delay: index * 0.05 }}
className="relative" className="relative"
> >
<Card className="group h-full hover:shadow-md transition-all duration-200"> {/* Web overlay decorative */}
<div className="absolute inset-0 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.06]">
<svg width="100%" height="100%" viewBox="0 0 180 180" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
{Array.from({ length: 6 }).map((_, i) => (
<line key={`wl-${i}`} x1="0" y1={i * 36} x2="180" y2={i * 36} stroke="#CC0000" strokeWidth="0.5" />
))}
{Array.from({ length: 6 }).map((_, i) => (
<line key={`wc-${i}`} x1={i * 36} y1="0" x2={i * 36} y2="180" stroke="#CC0000" strokeWidth="0.5" />
))}
{Array.from({ length: 6 }).map((_, i) => (
<line key={`wd-${i}`} x1="0" y1={i * 36} x2={180 - i * 36} y2="180" stroke="#0033CC" strokeWidth="0.5" />
))}
</svg>
</div>
{/* Comic action lines on hover */}
<div className="absolute -inset-2 pointer-events-none z-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<svg width="100%" height="100%" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="100" y1="0" x2="100" y2="20" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
<line x1="100" y1="180" x2="100" y2="200" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
<line x1="0" y1="100" x2="20" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
<line x1="180" y1="100" x2="200" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
<line x1="30" y1="30" x2="44" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
<line x1="170" y1="30" x2="156" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
<line x1="30" y1="170" x2="44" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
<line x1="170" y1="170" x2="156" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="100" cy="100" r="90" stroke="#CC0000" strokeWidth="0.5" strokeDasharray="4 4" opacity="0.4" />
<circle cx="100" cy="100" r="80" stroke="#0033CC" strokeWidth="0.5" strokeDasharray="3 5" opacity="0.3" />
</svg>
</div>
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
{/* Red/Blue gradient top strip */}
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
<CardContent className="p-6 flex flex-col"> <CardContent className="p-6 flex flex-col">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{title}</p> <p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-3xl font-bold tracking-tight"> <p className="text-3xl font-bold tracking-tight text-[#111111] dark:text-white">
{isNumeric ? display : value} {isNumeric ? display : value}
</p> </p>
{description && ( {description && (
<p className="text-xs text-muted-foreground">{description}</p> <p className="text-xs text-[#888888] dark:text-[#666666]">{description}</p>
)} )}
{trend && ( {trend && (
<span className={cn( <span className={cn(
@@ -134,8 +172,8 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
</span> </span>
)} )}
</div> </div>
<div className={cn("flex h-12 w-12 items-center justify-center rounded-xl shrink-0", color.bg)}> <div className={`flex h-12 w-12 items-center justify-center rounded-xl shrink-0 ${color.bg}`}>
<Icon className={cn("h-6 w-6", color.text)} /> <Icon className={`h-6 w-6 ${color.text}`} />
</div> </div>
</div> </div>
@@ -179,9 +217,9 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
{title === "Conversion Rate" && conversionRate !== undefined && ( {title === "Conversion Rate" && conversionRate !== undefined && (
<div className="mt-3"> <div className="mt-3">
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden"> <div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full rounded-full bg-gradient-to-r from-[#f43f5e] to-[#fda4af]" style={{ width: `${Math.min(conversionRate, 100)}%` }} /> <div className="h-full rounded-full bg-gradient-to-r from-[#CC0000] to-[#0033CC]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
</div> </div>
<p className="text-xs text-muted-foreground mt-1">{conversionRate}% conversion rate</p> <p className="text-xs text-[#888888] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
</div> </div>
)} )}
</CardContent> </CardContent>
+46 -3
View File
@@ -5,7 +5,6 @@ import { usePathname } from "next/navigation"
import { motion, AnimatePresence } from "framer-motion" import { motion, AnimatePresence } from "framer-motion"
import { Sidebar } from "./sidebar" import { Sidebar } from "./sidebar"
import { Topbar } from "./topbar" import { Topbar } from "./topbar"
import { SystemMonitor } from "./system-monitor"
interface AppShellProps { interface AppShellProps {
children: React.ReactNode children: React.ReactNode
@@ -34,8 +33,52 @@ export function AppShell({ children }: AppShellProps) {
} }
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
<SystemMonitor /> style={{
backgroundImage: "radial-gradient(circle, #e6202010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
backgroundSize: "28px 28px, 14px 14px",
backgroundPosition: "0 0, 7px 7px",
}}
>
{/* Spider-Man top gradient bar */}
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#e62020] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF6666] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
{/* Corner spider webs */}
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="0" y1="0" x2="180" y2="0" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
</svg>
</div>
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
<line x1="0" y1="0" x2="180" y2="0" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#e62020" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
</svg>
</div>
<Sidebar <Sidebar
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
onToggle={toggleSidebar} onToggle={toggleSidebar}
+50 -21
View File
@@ -5,6 +5,7 @@ import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { motion, AnimatePresence } from "framer-motion" import { motion, AnimatePresence } from "framer-motion"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { SystemMonitor } from "./system-monitor"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar" import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
@@ -18,10 +19,12 @@ import {
PanelLeftClose, PanelLeftClose,
MessageSquare, MessageSquare,
Bot, Bot,
Facebook,
} from "lucide-react" } from "lucide-react"
import { COMPANY_NAME } from "@/lib/constants"
import { useUser } from "@/providers/user-provider" import { useUser } from "@/providers/user-provider"
import { useNotifications } from "@/providers/notification-provider" import { useNotifications } from "@/providers/notification-provider"
import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-dialog"
const navItems = [ const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
@@ -43,7 +46,9 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
const pathname = usePathname() const pathname = usePathname()
const { user } = useUser() const { user } = useUser()
const { unreadChatCount } = useNotifications() const { unreadChatCount } = useNotifications()
const [fbDialogOpen, setFbDialogOpen] = useState(false)
if (!user) return null if (!user) return null
const isAdmin = user.role === "admin" || user.role === "super_admin"
const initials = user.name.split(" ").map((n) => n[0]).join("") const initials = user.name.split(" ").map((n) => n[0]).join("")
const sidebarContent = ( const sidebarContent = (
@@ -56,24 +61,11 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
{/* Logo */} {/* Logo */}
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}> <div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
<Link href="/" className="flex items-center gap-3 overflow-hidden"> <Link href="/" className="flex items-center gap-3 overflow-hidden">
<img {collapsed ? (
src="/logo/CompanyLogo.png" <div className="bc-logo" style={{padding: "8px 3px", fontSize: "16px"}}>B<span className="accent">C</span></div>
alt={COMPANY_NAME} ) : (
className="h-8 w-8 shrink-0 rounded-lg object-contain" <div className="bc-logo">Black<span className="accent">Cipher</span></div>
/> )}
<AnimatePresence mode="wait">
{!collapsed && (
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.15 }}
className="text-sm font-semibold whitespace-nowrap"
>
{COMPANY_NAME}
</motion.span>
)}
</AnimatePresence>
</Link> </Link>
{!collapsed && ( {!collapsed && (
<Button <Button
@@ -143,6 +135,39 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
</Link> </Link>
) )
})} })}
{isAdmin && collapsed && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setFbDialogOpen(true)}
className="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<Facebook className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="ml-2">
Facebook Accounts
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isAdmin && !collapsed && (
<button
onClick={() => setFbDialogOpen(true)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<Facebook className="h-5 w-5 shrink-0" />
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="truncate"
>
Facebook Accounts
</motion.span>
</button>
)}
</nav> </nav>
{/* Collapse toggle at bottom (only in collapsed mode) */} {/* Collapse toggle at bottom (only in collapsed mode) */}
@@ -159,6 +184,8 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
</div> </div>
)} )}
<SystemMonitor collapsed={collapsed} />
{/* User info */} {/* User info */}
<div className={cn("border-t border-sidebar-border p-3", collapsed && "flex justify-center")}> <div className={cn("border-t border-sidebar-border p-3", collapsed && "flex justify-center")}>
{collapsed ? ( {collapsed ? (
@@ -185,10 +212,12 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
return ( return (
<> <>
{/* Desktop sidebar */} {/* Desktop sidebar */}
<aside className="hidden lg:fixed lg:inset-y-0 lg:z-30 lg:flex"> <aside className="hidden lg:fixed lg:inset-y-0 lg:z-30 lg:flex border-r border-sidebar-border">
{sidebarContent} {sidebarContent}
</aside> </aside>
<FacebookAccountsDialog open={fbDialogOpen} onOpenChange={setFbDialogOpen} />
{/* Mobile sidebar overlay */} {/* Mobile sidebar overlay */}
<AnimatePresence> <AnimatePresence>
{mobileOpen && ( {mobileOpen && (
@@ -205,7 +234,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: -300 }} exit={{ x: -300 }}
transition={{ type: "spring", damping: 30, stiffness: 300 }} transition={{ type: "spring", damping: 30, stiffness: 300 }}
className="fixed inset-y-0 left-0 z-50 lg:hidden" className="fixed inset-y-0 left-0 z-50 lg:hidden border-r border-sidebar-border"
> >
{sidebarContent} {sidebarContent}
</motion.aside> </motion.aside>
+32 -9
View File
@@ -3,9 +3,13 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
const RAM_LIMIT_MB = 8192 const RAM_LIMIT_MB = 8192
const CPU_LIMIT_PCT = 400 // 4 cores * 100% const CPU_LIMIT_PCT = 400
export function SystemMonitor() { interface SystemMonitorProps {
collapsed: boolean
}
export function SystemMonitor({ collapsed }: SystemMonitorProps) {
const [rssMB, setRssMB] = useState(0) const [rssMB, setRssMB] = useState(0)
const [cpuPct, setCpuPct] = useState(0) const [cpuPct, setCpuPct] = useState(0)
@@ -30,14 +34,33 @@ export function SystemMonitor() {
const ramOver = rssMB > RAM_LIMIT_MB const ramOver = rssMB > RAM_LIMIT_MB
const cpuOver = cpuPct > CPU_LIMIT_PCT const cpuOver = cpuPct > CPU_LIMIT_PCT
if (collapsed) {
return (
<div className="border-t border-sidebar-border px-3 py-2 flex justify-center gap-1.5">
<span className={ramOver ? "text-red-400" : "text-[var(--theme-primary,#3b91f7)]"}>
{rssMB}
</span>
<span className={cpuOver ? "text-red-400" : "text-[var(--theme-primary,#3b91f7)]"}>
{cpuPct}%
</span>
</div>
)
}
return ( return (
<div className="fixed top-0 left-0 z-[9999] flex items-center gap-3 px-3 py-1 text-[11px] font-mono bg-black/80 rounded-br-lg select-none"> <div className="border-t border-sidebar-border px-3 py-2.5">
<span className={ramOver ? "text-red-400" : "text-green-400"}> <div className="flex items-center justify-between text-[11px] font-mono">
RAM: {rssMB}MB / 8192MB <span className="text-sidebar-foreground/50">RAM</span>
</span> <span className={ramOver ? "text-red-400 font-semibold" : "text-sidebar-foreground/80"}>
<span className={cpuOver ? "text-red-400" : "text-green-400"}> {rssMB}MB / {RAM_LIMIT_MB}MB
CPU: {cpuPct}% </span>
</span> </div>
<div className="flex items-center justify-between text-[11px] font-mono mt-1">
<span className="text-sidebar-foreground/50">CPU</span>
<span className={cpuOver ? "text-red-400 font-semibold" : "text-sidebar-foreground/80"}>
{cpuPct}%
</span>
</div>
</div> </div>
) )
} }
+27 -5
View File
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useUser } from "@/providers/user-provider"; import { useUser } from "@/providers/user-provider";
import { useNotifications } from "@/providers/notification-provider"; import { useNotifications } from "@/providers/notification-provider";
import { BugReportModal } from "@/components/shared/bug-report-modal";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -27,6 +28,7 @@ import {
LogOut, LogOut,
User, User,
Settings, Settings,
Bug,
CheckCheck, CheckCheck,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
@@ -43,6 +45,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
useNotifications(); useNotifications();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const [bugModalOpen, setBugModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
@@ -67,11 +70,18 @@ export function Topbar({ onMenuClick }: TopbarProps) {
.toUpperCase(); .toUpperCase();
return ( return (
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-background px-4 lg:px-6"> <header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-white dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden">
{/* Red/Blue diagonal split accent */}
<div className="absolute right-0 top-0 bottom-0 w-[6px] bg-gradient-to-b from-[#CC0000] via-[#0033CC] to-[#CC0000] dark:from-[#FF1111] dark:via-[#1144FF] dark:to-[#FF1111]" />
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-2 mr-2 shrink-0"> <div className="flex items-center gap-2 mr-2 shrink-0">
<div className="w-3 h-3 rounded-full bg-[#0d9488]" /> <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<span className="text-[#0d9488] font-bold text-sm tracking-wide"> <circle cx="14" cy="14" r="13" fill="#CC0000" />
<circle cx="14" cy="14" r="8" fill="#0033CC" />
<path d="M14 6 L16 12 L22 12 L17 15 L19 21 L14 17 L9 21 L11 15 L6 12 L12 12 Z" fill="white" />
</svg>
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide">
CRM CRM
</span> </span>
</div> </div>
@@ -88,10 +98,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
{/* Search */} {/* Search */}
<div className="relative hidden flex-1 sm:block md:w-80"> <div className="relative hidden flex-1 sm:block md:w-80">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#888888] dark:text-[#666666]" />
<Input <Input
placeholder="Search leads, companies..." placeholder="Search leads, companies..."
className="h-9 w-full max-w-sm pl-9 bg-muted/50" className="h-9 w-full max-w-sm pl-9 bg-[#F0F0F0] dark:bg-[#1E1E1E] border-[#E0E0E0] dark:border-[#222222] text-[#111111] dark:text-white"
/> />
</div> </div>
@@ -106,6 +116,17 @@ export function Topbar({ onMenuClick }: TopbarProps) {
<Search className="h-5 w-5" /> <Search className="h-5 w-5" />
</Button> </Button>
{/* Report a Bug */}
<Button
variant="ghost"
size="icon"
onClick={() => setBugModalOpen(true)}
className="text-muted-foreground"
title="Report a Bug"
>
<Bug className="h-5 w-5" />
</Button>
{/* Theme toggle */} {/* Theme toggle */}
<Button <Button
variant="ghost" variant="ghost"
@@ -240,6 +261,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<BugReportModal open={bugModalOpen} onClose={() => setBugModalOpen(false)} />
</header> </header>
); );
} }
@@ -0,0 +1,102 @@
"use client"
import { useEffect, useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { ShieldAlert, RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
interface Account {
id: string
label: string
is_active: boolean
last_scrape_at: string | null
last_success_at: string | null
last_error_at: string | null
consecutive_failures: number
flagged: boolean
flagged_reason: string | null
last_leads_found: number
last_success: boolean | null
}
export function FacebookAccountsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
const [accounts, setAccounts] = useState<Account[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (open) load()
}, [open])
async function load() {
setLoading(true)
try {
const res = await fetch("/api/settings/facebook/accounts")
if (res.ok) {
setAccounts(await res.json())
}
} catch {}
setLoading(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader className="flex flex-row items-center justify-between">
<div>
<DialogTitle>Facebook Accounts</DialogTitle>
<DialogDescription>
Status of Facebook profiles used for lead scraping
</DialogDescription>
</div>
<Button variant="ghost" size="icon" onClick={load} disabled={loading}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</DialogHeader>
{loading ? (
<div className="text-center py-8 text-muted-foreground">Loading...</div>
) : accounts.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No accounts configured</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Label</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Scrape</TableHead>
<TableHead>Leads</TableHead>
<TableHead>Failures</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.label}</TableCell>
<TableCell>
{a.flagged ? (
<Badge variant="destructive" className="gap-1">
<ShieldAlert className="h-3 w-3" />
{a.flagged_reason || "Flagged"}
</Badge>
) : a.is_active ? (
<Badge variant="default" className="bg-green-600">Active</Badge>
) : (
<Badge variant="secondary">Disabled</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{a.last_scrape_at ? new Date(a.last_scrape_at).toLocaleString() : "Never"}
</TableCell>
<TableCell>{a.last_leads_found}</TableCell>
<TableCell>{a.consecutive_failures}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</DialogContent>
</Dialog>
)
}
+76 -1
View File
@@ -6,7 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Sun, Moon, Monitor } from "lucide-react" import { useWebsiteTheme } from "@/providers/website-theme-provider"
import { Sun, Moon, Monitor, Check } from "lucide-react"
const COLOR_THEME_KEY = "crm-color-theme" const COLOR_THEME_KEY = "crm-color-theme"
@@ -44,8 +45,17 @@ function applyColorTheme(theme: string) {
localStorage.setItem(COLOR_THEME_KEY, theme) localStorage.setItem(COLOR_THEME_KEY, theme)
} }
const websiteThemes = [
{
id: "spidey",
name: "Spidey",
description: "Current website theme",
},
]
export function ThemeSettings() { export function ThemeSettings() {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const { websiteTheme, setWebsiteTheme } = useWebsiteTheme()
const [colorTheme, setColorTheme] = useState("default") const [colorTheme, setColorTheme] = useState("default")
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
@@ -119,6 +129,71 @@ export function ThemeSettings() {
</RadioGroup> </RadioGroup>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Website Themes</CardTitle>
<CardDescription>
Select your website&apos;s visual theme.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{websiteThemes.map((wt) => {
const isActive = websiteTheme === wt.id
return (
<button
key={wt.id}
type="button"
onClick={() => setWebsiteTheme(wt.id)}
className={cn(
"group relative flex flex-col items-start gap-3 rounded-xl border-2 p-4 text-left transition-all",
"hover:bg-accent cursor-pointer",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "border-border hover:border-muted-foreground/30"
)}
>
{isActive && (
<span className="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
<Check className="h-3 w-3" />
</span>
)}
<div className="flex h-20 w-full items-center justify-center overflow-hidden rounded-lg border border-border bg-background">
<div className="flex h-full w-full">
<div className="flex w-1/3 flex-col gap-0.5 bg-[#0a0a0f] p-1.5">
<div className="h-1 w-full rounded-sm bg-[#1a1a24]" />
<div className="h-1 w-2/3 rounded-sm bg-[#1a1a24]" />
<div className="mt-auto h-1.5 w-full rounded-sm bg-[#1a1a24]" />
</div>
<div className="flex flex-1 flex-col">
<div className="flex h-5 items-center gap-1 bg-[#CC0000] px-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
</div>
<div className="flex flex-1 items-center justify-center bg-[#141414]">
<div className="h-2 w-2 rounded-full bg-[#CC0000]/40" />
</div>
</div>
</div>
</div>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-sm font-medium">{wt.name}</span>
{isActive && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
Current
</span>
)}
</div>
</button>
)
})}
</div>
</CardContent>
</Card>
</div> </div>
) )
} }
+188
View File
@@ -0,0 +1,188 @@
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { X, Bug, Loader2, CheckCircle } from "lucide-react"
interface BugReportModalProps {
open: boolean
onClose: () => void
}
export function BugReportModal({ open, onClose }: BugReportModalProps) {
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [severity, setSeverity] = useState("medium")
const [loading, setLoading] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setLoading(true)
try {
const res = await fetch("/api/bug-reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
description,
severity,
page_url: window.location.href,
}),
})
if (res.ok) {
setSubmitted(true)
setTitle("")
setDescription("")
setSeverity("medium")
} else {
const data = await res.json().catch(() => ({}))
setError(data.error || "Failed to submit bug report.")
}
} catch {
setError("Connection error. Please try again.")
} finally {
setLoading(false)
}
}
const handleClose = () => {
setSubmitted(false)
setError("")
onClose()
}
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleClose}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="relative w-full max-w-md rounded-xl border bg-white dark:bg-[#141414] p-6 shadow-2xl"
>
<button
onClick={handleClose}
className="absolute right-4 top-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
{submitted ? (
<div className="flex flex-col items-center py-8 text-center">
<CheckCircle className="h-12 w-12 text-emerald-500 mb-4" />
<h3 className="text-lg font-semibold text-[#111111] dark:text-white mb-2">
Bug Report Submitted
</h3>
<p className="text-sm text-[#666666] dark:text-[#AAAAAA]">
Thank you for your report. The development team will investigate.
</p>
<button
onClick={handleClose}
className="mt-6 rounded-lg bg-[#CC0000] px-6 py-2 text-sm font-medium text-white hover:bg-[#AA0000] transition-colors"
>
Done
</button>
</div>
) : (
<>
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30">
<Bug className="h-5 w-5 text-[#CC0000]" />
</div>
<div>
<h3 className="text-lg font-semibold text-[#111111] dark:text-white">
Report a Bug
</h3>
<p className="text-xs text-[#666666] dark:text-[#AAAAAA]">
Help us improve the system
</p>
</div>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-500/10 px-4 py-2 text-sm text-red-500">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
Title <span className="text-[#CC0000]">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Brief description of the issue"
required
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
Description <span className="text-[#CC0000]">*</span>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What happened? What did you expect?"
rows={4}
required
className="w-full resize-none rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
Severity
</label>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
>
<option value="low">Low Minor cosmetic issue</option>
<option value="medium">Medium Affects functionality</option>
<option value="high">High Major feature broken</option>
<option value="critical">Critical System is blocked</option>
</select>
</div>
<div className="rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] px-3 py-2">
<p className="text-xs text-[#888888]">
<span className="font-medium">Page:</span> {typeof window !== "undefined" ? window.location.href : ""}
</p>
</div>
<button
type="submit"
disabled={loading}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#CC0000] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#AA0000] disabled:opacity-50 transition-colors"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Submitting..." : "Submit Bug Report"}
</button>
</form>
</>
)}
</motion.div>
</div>
)}
</AnimatePresence>
)
}
+2 -2
View File
@@ -19,8 +19,8 @@ export function PageHeader({ title, description, children, className }: PageHead
className={cn("flex items-center justify-between pb-6", className)} className={cn("flex items-center justify-between pb-6", className)}
> >
<div> <div>
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#0d9488] pl-4">{title}</h1> <h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#CC0000] dark:border-[#FF1111] pl-4 text-[#111111] dark:text-white">{title}</h1>
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>} {description && <p className="text-sm text-[#444444] dark:text-[#AAAAAA] mt-1">{description}</p>}
</div> </div>
{children && <div className="flex items-center gap-3">{children}</div>} {children && <div className="flex items-center gap-3">{children}</div>}
</motion.div> </motion.div>
+5 -5
View File
@@ -30,11 +30,11 @@ export function getDashboardStats(period: string): DashboardStats {
}) })
return [ return [
{ name: "Open", value: statusCounts.open, color: "#3b82f6" }, { name: "Open", value: statusCounts.open, color: "#CC0000" },
{ name: "Contacted", value: statusCounts.contacted, color: "#f59e0b" }, { name: "Contacted", value: statusCounts.contacted, color: "#0033CC" },
{ name: "Pending", value: statusCounts.pending, color: "#8b5cf6" }, { name: "Pending", value: statusCounts.pending, color: "#FFFFFF" },
{ name: "Closed", value: statusCounts.closed, color: "#10b981" }, { name: "Closed", value: statusCounts.closed, color: "#0033CC" },
{ name: "Ignored", value: statusCounts.ignored, color: "#6B7280" }, { name: "Ignored", value: statusCounts.ignored, color: "#CC0000" },
] ]
} }
+9
View File
@@ -5,6 +5,7 @@ export const users: User[] = [
id: "u-super", id: "u-super",
name: "Jason Oosthuizen", name: "Jason Oosthuizen",
email: "jason@coastalit.com", email: "jason@coastalit.com",
phone: "+27 82 555 0100",
role: "super_admin", role: "super_admin",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Jason+Oosthuizen&background=0f172a&color=fff&size=128", avatar: "https://ui-avatars.com/api/?name=Jason+Oosthuizen&background=0f172a&color=fff&size=128",
@@ -14,6 +15,7 @@ export const users: User[] = [
id: "u1", id: "u1",
name: "Sarah Chen", name: "Sarah Chen",
email: "SarahChen@coastit.co.za", email: "SarahChen@coastit.co.za",
phone: "+27 72 555 0101",
role: "admin", role: "admin",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=128", avatar: "https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=128",
@@ -23,6 +25,7 @@ export const users: User[] = [
id: "u2", id: "u2",
name: "Marcus Johnson", name: "Marcus Johnson",
email: "marcus@coastalit.com", email: "marcus@coastalit.com",
phone: "+27 62 555 0102",
role: "sales", role: "sales",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Marcus+Johnson&background=2563eb&color=fff&size=128", avatar: "https://ui-avatars.com/api/?name=Marcus+Johnson&background=2563eb&color=fff&size=128",
@@ -32,6 +35,7 @@ export const users: User[] = [
id: "u3", id: "u3",
name: "Emily Rodriguez", name: "Emily Rodriguez",
email: "emily@coastalit.com", email: "emily@coastalit.com",
phone: "+27 73 555 0103",
role: "sales", role: "sales",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Emily+Rodriguez&background=3b82f6&color=fff&size=128", avatar: "https://ui-avatars.com/api/?name=Emily+Rodriguez&background=3b82f6&color=fff&size=128",
@@ -41,6 +45,7 @@ export const users: User[] = [
id: "u4", id: "u4",
name: "David Kim", name: "David Kim",
email: "david@coastalit.com", email: "david@coastalit.com",
phone: "+27 64 555 0104",
role: "sales", role: "sales",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=David+Kim&background=60a5fa&color=fff&size=128", avatar: "https://ui-avatars.com/api/?name=David+Kim&background=60a5fa&color=fff&size=128",
@@ -50,6 +55,7 @@ export const users: User[] = [
id: "u5", id: "u5",
name: "Jessica Patel", name: "Jessica Patel",
email: "jessica@coastalit.com", email: "jessica@coastalit.com",
phone: "+27 82 555 0105",
role: "sales", role: "sales",
active: false, active: false,
avatar: "https://ui-avatars.com/api/?name=Jessica+Patel&background=93c5fd&color=fff&size=128", avatar: "https://ui-avatars.com/api/?name=Jessica+Patel&background=93c5fd&color=fff&size=128",
@@ -59,6 +65,7 @@ export const users: User[] = [
id: "u6", id: "u6",
name: "Alex Thompson", name: "Alex Thompson",
email: "alex@coastalit.com", email: "alex@coastalit.com",
phone: "+27 72 555 0106",
role: "sales", role: "sales",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Alex+Thompson&background=1d4ed8&color=fff&size=128", avatar: "https://ui-avatars.com/api/?name=Alex+Thompson&background=1d4ed8&color=fff&size=128",
@@ -68,6 +75,7 @@ export const users: User[] = [
id: "u7", id: "u7",
name: "Rachel Williams", name: "Rachel Williams",
email: "rachel@coastalit.com", email: "rachel@coastalit.com",
phone: "+27 64 555 0107",
role: "sales", role: "sales",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Rachel+Williams&background=2563eb&color=fff&size=128", avatar: "https://ui-avatars.com/api/?name=Rachel+Williams&background=2563eb&color=fff&size=128",
@@ -77,6 +85,7 @@ export const users: User[] = [
id: "u8", id: "u8",
name: "Tom Nakamura", name: "Tom Nakamura",
email: "tom@coastalit.com", email: "tom@coastalit.com",
phone: "+27 83 555 0108",
role: "admin", role: "admin",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Tom+Nakamura&background=3b82f6&color=fff&size=128", avatar: "https://ui-avatars.com/api/?name=Tom+Nakamura&background=3b82f6&color=fff&size=128",
+508
View File
@@ -0,0 +1,508 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { io, Socket } from "socket.io-client"
const STUN_SERVERS = {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
],
}
const SIGNALING_URL = process.env.NEXT_PUBLIC_SIGNALING_URL || "http://localhost:3007"
export type CallState = "idle" | "calling" | "ringing" | "connecting" | "connected" | "ended" | "missed" | "rejected" | "offline" | "busy" | "waiting" | "participant_joined"
export interface PlatformUser {
id: string
username: string
firstName: string
lastName: string
phone: string | null
avatar: string | null
online: boolean
}
export interface CallerInfo {
id: string
username: string
firstName: string
lastName: string
avatar: string | null
}
export interface Participant {
id: string
label: string
}
export function useWebRTCCall(options?: { anonymous?: boolean }) {
const isAnonymous = options?.anonymous ?? false
const [callState, setCallState] = useState<CallState>("idle")
const [isMuted, setIsMuted] = useState(false)
const [isSpeakerOn, setIsSpeakerOn] = useState(false)
const [callDuration, setCallDuration] = useState(0)
const [error, setError] = useState<string | null>(null)
const [isReady, setIsReady] = useState(false)
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null)
const [callerInfo, setCallerInfo] = useState<CallerInfo | null>(null)
const [calleeNumber, setCalleeNumber] = useState<string>("")
const [tokenLoading, setTokenLoading] = useState(true)
const [participants, setParticipants] = useState<Participant[]>([])
const socketRef = useRef<Socket | null>(null)
const pcRef = useRef<RTCPeerConnection | null>(null)
const localStreamRef = useRef<MediaStream | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const timerRef = useRef<NodeJS.Timeout | null>(null)
const pendingCallRef = useRef<{ to: string; sdp: any } | null>(null)
const roomIdRef = useRef<string | null>(null)
const startTimer = useCallback(() => {
setCallDuration(0)
if (timerRef.current) clearInterval(timerRef.current)
timerRef.current = setInterval(() => {
setCallDuration((prev) => prev + 1)
}, 1000)
}, [])
useEffect(() => {
let socket: Socket | null = null
let cancelled = false
async function init() {
try {
let authToken: string | undefined
if (!isAnonymous) {
const res = await fetch("/api/auth/jwt")
if (res.ok) {
const data = await res.json()
authToken = data.token
}
if (cancelled) return
}
socket = io(SIGNALING_URL, {
auth: authToken ? { token: authToken } : undefined,
transports: ["websocket", "polling"],
})
socket.on("connect", () => setIsReady(true))
socket.on("disconnect", () => setIsReady(false))
if (!isAnonymous) {
socket.on("call:incoming", ({ from, sdp, callerInfo: info }) => {
setCallerInfo(info)
setCallState("ringing")
pendingCallRef.current = { to: from, sdp }
})
socket.on("call:answered", async ({ from, sdp }) => {
setCallState("connecting")
if (pcRef.current && sdp) {
try {
await pcRef.current.setRemoteDescription(new RTCSessionDescription(sdp))
} catch {}
}
})
socket.on("call:ice-candidate", async ({ from, candidate }) => {
if (pcRef.current && candidate) {
try {
await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate))
} catch {}
}
})
socket.on("call:ended", ({ from }) => {
setCallState("ended")
cleanupCall()
})
socket.on("call:rejected", ({ from }) => {
setCallState("rejected")
cleanupCall()
})
socket.on("call:busy", ({ from }) => {
setCallState("busy")
cleanupCall()
})
socket.on("call:user-offline", ({ userId }) => {
setCallState("offline")
cleanupCall()
})
}
socket.on("room:waiting", ({ roomId, participants: pList }) => {
setParticipants(pList || [])
setCallState("waiting")
})
socket.on("room:participant-joined", ({ roomId, participants: pList }) => {
setParticipants(pList || [])
setCallState("participant_joined")
})
socket.on("room:initiate-offer", async ({ roomId, participants: pList }) => {
setParticipants(pList || [])
setCallState("participant_joined")
try {
const pc = new RTCPeerConnection(STUN_SERVERS)
pc.onicecandidate = (e) => {
if (e.candidate && roomIdRef.current) {
socket?.emit("room:ice-candidate", {
roomId: roomIdRef.current,
candidate: e.candidate.toJSON(),
})
}
}
pc.ontrack = (e) => {
setRemoteStream(e.streams[0])
const audio = new Audio()
audio.srcObject = e.streams[0]
audio.autoplay = true
audioRef.current = audio
audio.play().catch(() => {})
}
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
setCallState("ended")
cleanupCall()
}
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
localStreamRef.current = stream
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
pcRef.current = pc
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
setCallState("connecting")
socket?.emit("room:offer", { roomId, sdp: pc.localDescription })
} catch (e) {
setError("Failed to start call")
cleanupCall()
}
})
socket.on("room:offer", async ({ sdp }) => {
try {
setCallState("connecting")
const pc = new RTCPeerConnection(STUN_SERVERS)
pc.onicecandidate = (e) => {
if (e.candidate && roomIdRef.current) {
socket?.emit("room:ice-candidate", {
roomId: roomIdRef.current,
candidate: e.candidate.toJSON(),
})
}
}
pc.ontrack = (e) => {
setRemoteStream(e.streams[0])
const audio = new Audio()
audio.srcObject = e.streams[0]
audio.autoplay = true
audioRef.current = audio
audio.play().catch(() => {})
}
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
setCallState("ended")
cleanupCall()
}
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
localStreamRef.current = stream
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
pcRef.current = pc
await pc.setRemoteDescription(new RTCSessionDescription(sdp))
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
socket?.emit("room:answer", { roomId: roomIdRef.current, sdp: pc.localDescription })
setCallState("connected")
startTimer()
} catch (e) {
setError("Failed to join call")
cleanupCall()
}
})
socket.on("room:answer", async ({ sdp }) => {
if (pcRef.current) {
try {
await pcRef.current.setRemoteDescription(new RTCSessionDescription(sdp))
setCallState("connected")
startTimer()
} catch {}
}
})
socket.on("room:ice-candidate", async ({ candidate }) => {
if (pcRef.current) {
try {
await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate))
} catch {}
}
})
socket.on("room:participant-left", ({ roomId, participants: pList }) => {
setParticipants(pList || [])
setCallState("ended")
cleanupCall()
})
socketRef.current = socket
} finally {
setTokenLoading(false)
}
}
init()
return () => {
cancelled = true
if (socket) socket.disconnect()
cleanupCall()
}
}, [isAnonymous])
const cleanupCall = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
if (pcRef.current) {
pcRef.current.close()
pcRef.current = null
}
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach((t) => t.stop())
localStreamRef.current = null
}
setRemoteStream(null)
setIsMuted(false)
setIsSpeakerOn(false)
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.srcObject = null
audioRef.current = null
}
setCallDuration(0)
pendingCallRef.current = null
roomIdRef.current = null
setParticipants([])
}, [])
const createPeerConnection = useCallback(async () => {
const pc = new RTCPeerConnection(STUN_SERVERS)
pc.onicecandidate = (e) => {
if (e.candidate && pendingCallRef.current) {
socketRef.current?.emit("call:ice-candidate", {
to: pendingCallRef.current.to,
candidate: e.candidate.toJSON(),
})
}
}
pc.ontrack = (e) => {
setRemoteStream(e.streams[0])
const audio = new Audio()
audio.srcObject = e.streams[0]
audio.autoplay = true
audioRef.current = audio
audio.play().catch(() => {})
}
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
setCallState("ended")
cleanupCall()
}
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
localStreamRef.current = stream
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
} catch {
setError("Microphone access denied")
}
pcRef.current = pc
return pc
}, [cleanupCall])
const lookupUser = useCallback(async (phone: string): Promise<PlatformUser | null> => {
return new Promise((resolve) => {
socketRef.current?.emit("user:lookup", { phone }, (result: any) => {
if (result?.found) {
resolve(result.user)
} else {
resolve(null)
}
})
})
}, [])
const makeCall = useCallback(async (phoneNumber: string) => {
try {
setError(null)
setCalleeNumber(phoneNumber)
const user = await lookupUser(phoneNumber)
if (!user) {
setCallState("missed")
setError("Not on Platform")
return
}
if (!user.online) {
setCallState("offline")
setError("User is offline")
return
}
const pc = await createPeerConnection()
setCallState("calling")
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
socketRef.current?.emit("call:offer", {
to: user.id,
sdp: pc.localDescription,
callerInfo: null,
})
pendingCallRef.current = { to: user.id, sdp: null }
setCallState("ringing")
} catch {
setError("Failed to start call")
cleanupCall()
}
}, [createPeerConnection, lookupUser, cleanupCall])
const acceptCall = useCallback(async () => {
if (!pendingCallRef.current) return
setCallState("connecting")
try {
const pc = await createPeerConnection()
await pc.setRemoteDescription(new RTCSessionDescription(pendingCallRef.current.sdp))
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
socketRef.current?.emit("call:answer", {
to: pendingCallRef.current.to,
sdp: pc.localDescription,
})
setCallState("connected")
startTimer()
} catch {
setError("Failed to accept call")
cleanupCall()
}
}, [createPeerConnection, cleanupCall, startTimer])
const rejectCall = useCallback(() => {
if (pendingCallRef.current) {
socketRef.current?.emit("call:reject", { to: pendingCallRef.current.to })
}
setCallState("idle")
cleanupCall()
}, [cleanupCall])
const endCall = useCallback(() => {
if (roomIdRef.current) {
socketRef.current?.emit("room:leave", { roomId: roomIdRef.current })
}
if (!isAnonymous) {
if (callState === "ringing" && pendingCallRef.current) {
socketRef.current?.emit("call:reject", { to: pendingCallRef.current.to })
}
if (callState === "connected" || callState === "connecting" || callState === "calling") {
const to = pendingCallRef.current?.to
if (to) {
socketRef.current?.emit("call:end", { to })
}
}
}
setCallState("ended")
cleanupCall()
}, [callState, cleanupCall, isAnonymous])
const toggleSpeaker = useCallback(() => {
setIsSpeakerOn((prev) => !prev)
}, [])
const toggleMute = useCallback(() => {
if (localStreamRef.current) {
localStreamRef.current.getAudioTracks().forEach((track) => {
track.enabled = isMuted
})
setIsMuted(!isMuted)
}
}, [isMuted])
const formatDuration = useCallback((seconds: number) => {
const m = Math.floor(seconds / 60).toString().padStart(2, "0")
const s = (seconds % 60).toString().padStart(2, "0")
return `${m}:${s}`
}, [])
const createRoom = useCallback(() => {
const roomId = crypto.randomUUID?.() || Math.random().toString(36).slice(2, 14)
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || (typeof window !== "undefined" ? window.location.origin : "http://localhost:3000")
const link = `${baseUrl}/join/${roomId}`
return { roomId, link }
}, [])
const joinRoom = useCallback((roomId: string, displayName?: string) => {
roomIdRef.current = roomId
setCallState("calling")
socketRef.current?.emit("room:join", { roomId, displayName: displayName || "" })
}, [])
const leaveRoom = useCallback(() => {
if (roomIdRef.current) {
socketRef.current?.emit("room:leave", { roomId: roomIdRef.current })
}
setCallState("ended")
cleanupCall()
}, [cleanupCall])
return {
callState,
isCallActive: callState === "connected" || callState === "connecting" || callState === "ringing" || callState === "calling" || callState === "waiting" || callState === "participant_joined",
isIncoming: callState === "ringing" && !!callerInfo,
isMuted,
isSpeakerOn,
callDuration,
error,
isReady,
remoteStream,
callerInfo,
calleeNumber,
participants,
createRoom,
joinRoom,
leaveRoom,
makeCall,
acceptCall,
rejectCall,
endCall,
toggleMute,
toggleSpeaker,
lookupUser,
formatDuration,
tokenLoading,
}
}
+61 -26
View File
@@ -1,41 +1,76 @@
import http from "http"
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001" const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
function parseUrl(url: string) {
const u = new URL(url)
return { hostname: u.hostname, port: parseInt(u.port) || 80, path: u.pathname + u.search }
}
export async function chatWithAI(message: string, jwtToken: string) { export async function chatWithAI(message: string, jwtToken: string) {
const res = await fetch(`${AI_SERVICE}/ai/chat`, { const body = JSON.stringify({ message })
method: "POST", const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/chat`)
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
body: JSON.stringify({ message }), const text = await new Promise<string>((resolve, reject) => {
const req = http.request(
{ hostname, port, path, method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}`, "Content-Length": Buffer.byteLength(body) } },
(res) => {
let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve(data)
} else {
reject(new Error(`AI error ${res.statusCode}: ${data.substring(0, 200)}`))
}
})
}
)
req.on("error", reject)
req.write(body)
req.end()
}) })
if (!res.ok) { const data = JSON.parse(text)
const text = await res.text()
throw new Error(`AI service error (${res.status}): ${text}`)
}
const data = await res.json()
return data.response || "" return data.response || ""
} }
export async function fetchJobs() { export async function checkAiServiceStatus() {
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/health`)
try { try {
const res = await fetch(`${AI_SERVICE}/ai/jobs`) await new Promise<void>((resolve, reject) => {
if (!res.ok) return [] const req = http.get({ hostname, port, path, timeout: 3000 }, (res) => {
const data = await res.json() let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => {
try { resolve(JSON.parse(data).status === "ok" ? undefined : reject(new Error("not ok"))) } catch { reject(new Error("bad response")) }
})
})
req.on("error", reject)
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
})
return true
} catch {
return false
}
}
export async function fetchJobs() {
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/jobs`)
try {
const text = await new Promise<string>((resolve, reject) => {
const req = http.get({ hostname, port, path, timeout: 5000 }, (res) => {
let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => resolve(data))
})
req.on("error", reject)
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
})
const data = JSON.parse(text)
return data.jobs || [] return data.jobs || []
} catch { } catch {
console.warn("Failed to fetch AI jobs") console.warn("Failed to fetch AI jobs")
return [] return []
} }
} }
export async function checkAiServiceStatus() {
try {
const res = await fetch(`${AI_SERVICE}/health`)
if (!res.ok) return false
const data = await res.json()
return data.status === "ok"
} catch {
console.warn("Failed to check AI service status")
return false
}
}
+24 -2
View File
@@ -17,6 +17,7 @@ export interface SessionUser {
id: string; id: string;
username: string; username: string;
email: string; email: string;
phone: string | null;
firstName: string; firstName: string;
lastName: string; lastName: string;
role: string; role: string;
@@ -85,7 +86,7 @@ export async function getUserByUsername(username: string) {
export async function getUserById(id: string) { export async function getUserById(id: string) {
const result = await query( const result = await query(
` SELECT u.id, u.username, u.email, u.first_name, u.last_name, ` SELECT u.id, u.username, u.email, u.phone, u.first_name, u.last_name,
u.is_active, u.avatar_url, u.is_active, u.avatar_url,
r.name AS role_name r.name AS role_name
FROM users u FROM users u
@@ -185,6 +186,7 @@ export function mapDbUserToSessionUser(
id: dbUser.id as string, id: dbUser.id as string,
username: dbUser.username as string, username: dbUser.username as string,
email: dbUser.email as string, email: dbUser.email as string,
phone: (dbUser.phone as string) || null,
firstName: dbUser.first_name as string, firstName: dbUser.first_name as string,
lastName: dbUser.last_name as string, lastName: dbUser.last_name as string,
role: roleMapping[roleName] || roleName.toLowerCase(), role: roleMapping[roleName] || roleName.toLowerCase(),
@@ -192,6 +194,27 @@ export function mapDbUserToSessionUser(
}; };
} }
export async function encryptPassword(password: string): Promise<string> {
const result = await query("SELECT encrypt_password($1) AS encrypted", [password]);
return result.rows[0]?.encrypted;
}
export async function decryptPassword(encrypted: string): Promise<string | null> {
try {
const result = await query("SELECT decrypt_password($1) AS decrypted", [encrypted]);
return result.rows[0]?.decrypted || null;
} catch {
return null;
}
}
export async function setSessionContext(userId: string, ip?: string) {
await query("SELECT set_config('app.current_user_id', $1, true)", [userId]);
if (ip) {
await query("SELECT set_config('app.current_ip', $1, true)", [ip]);
}
}
export async function createSession(userId: string, role: string) { export async function createSession(userId: string, role: string) {
const token = await signToken({ userId, role }); const token = await signToken({ userId, role });
@@ -201,7 +224,6 @@ export async function createSession(userId: string, role: string) {
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "strict", sameSite: "strict",
path: "/", path: "/",
maxAge: 60 * 60 * 24, // 24 hours
}); });
return token; return token;
+1 -1
View File
@@ -19,4 +19,4 @@ export const LEAD_SOURCES = [
export const ITEMS_PER_PAGE = 10 export const ITEMS_PER_PAGE = 10
export const COMPANY_NAME = "Coast IT" export const COMPANY_NAME = "Black Cipher"
+17
View File
@@ -0,0 +1,17 @@
import { createClient, SupabaseClient } from "@supabase/supabase-js"
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ""
let client: SupabaseClient | null = null
if (supabaseUrl && supabaseAnonKey) {
client = createClient(supabaseUrl, supabaseAnonKey)
}
export function getSupabase(): SupabaseClient {
if (!client) {
throw new Error("Supabase is not configured. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.")
}
return client
}
+1
View File
@@ -10,6 +10,7 @@ const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
const publicRoutes = [ const publicRoutes = [
"/login", "/login",
"/join",
"/api/auth/login", "/api/auth/login",
"/api/auth/logout", "/api/auth/logout",
"/_next/static", "/_next/static",
+1
View File
@@ -30,6 +30,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
id: u.id, id: u.id,
name: `${u.firstName} ${u.lastName}`, name: `${u.firstName} ${u.lastName}`,
email: u.email, email: u.email,
phone: u.phone || "",
role: u.role, role: u.role,
active: true, active: true,
avatar: u.avatar, avatar: u.avatar,
+92
View File
@@ -0,0 +1,92 @@
"use client"
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"
const WEBSITE_THEME_KEY = "crm-website-theme"
const themeClasses: Record<string, string> = {
spidey: "",
}
interface WebsiteThemeContextValue {
websiteTheme: string
setWebsiteTheme: (theme: string) => Promise<void>
loading: boolean
}
const WebsiteThemeContext = createContext<WebsiteThemeContextValue | null>(null)
function applyWebsiteTheme(theme: string) {
const prefix = "theme-"
const root = document.documentElement
const classes = root.className.split(" ").filter((c) => !c.startsWith(prefix))
const cls = themeClasses[theme]
if (cls) {
classes.push(cls)
}
root.className = classes.join(" ").trim()
}
function getStoredTheme(): string | null {
if (typeof window === "undefined") return null
return localStorage.getItem(WEBSITE_THEME_KEY)
}
function storeTheme(theme: string) {
if (typeof window === "undefined") return
localStorage.setItem(WEBSITE_THEME_KEY, theme)
}
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
const [websiteTheme, setWebsiteThemeState] = useState<string>("spidey")
const [loading, setLoading] = useState(true)
useEffect(() => {
const stored = getStoredTheme()
if (stored) {
setWebsiteThemeState(stored)
applyWebsiteTheme(stored)
setLoading(false)
return
}
fetch("/api/settings/website-theme")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
const theme = data?.websiteTheme || "spidey"
setWebsiteThemeState(theme)
storeTheme(theme)
applyWebsiteTheme(theme)
})
.catch(() => {
applyWebsiteTheme("spidey")
})
.finally(() => setLoading(false))
}, [])
const setWebsiteTheme = useCallback(async (theme: string) => {
setWebsiteThemeState(theme)
storeTheme(theme)
applyWebsiteTheme(theme)
try {
await fetch("/api/settings/website-theme", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ websiteTheme: theme }),
})
} catch {
console.warn("Failed to persist website theme")
}
}, [])
return (
<WebsiteThemeContext.Provider value={{ websiteTheme, setWebsiteTheme, loading }}>
{children}
</WebsiteThemeContext.Provider>
)
}
export function useWebsiteTheme() {
const ctx = useContext(WebsiteThemeContext)
if (!ctx) throw new Error("useWebsiteTheme must be used within WebsiteThemeProvider")
return ctx
}
+2 -1
View File
@@ -4,12 +4,13 @@ export type LeadStatus =
| "pending" | "pending"
| "closed" | "closed"
| "ignored"; | "ignored";
export type UserRole = "super_admin" | "admin" | "sales"; export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User { export interface User {
id: string; id: string;
name: string; name: string;
email: string; email: string;
phone?: string;
role: UserRole; role: UserRole;
active: boolean; active: boolean;
avatar: string; avatar: string;