Compare commits

..

100 Commits

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

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

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

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

Other:
- Added 'dev' to UserRole type
- Bug report modal UI with severity selector
- Added bug report button to topbar
2026-06-26 11:13:28 +02:00
caitlin fa39f2af9d Merge origin/main into master 2026-06-26 09:48:11 +02:00
caitlin 52a489759f Working on the mcp, this helps connect it 2026-06-25 10:25:00 +02:00
237 changed files with 19505 additions and 1829 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.git
*.md
.vscode
__pycache__
*.pyc
.DS_Store
.env.local
.env
.gitignore
.prettierrc
+58
View File
@@ -0,0 +1,58 @@
name: Build & Auto-Repair
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Install Playwright
run: npx playwright install chromium firefox
- name: Build check
id: build
continue-on-error: true
run: npx tsc --noEmit
- name: Run self-healing setup
if: steps.build.outcome == 'failure'
run: node scripts/setup.mjs --self-heal
- name: Run code repair agent
if: steps.build.outcome == 'failure'
env:
OLLAMA_HOST: ${{ vars.OLLAMA_HOST || 'http://localhost:11434' }}
run: node scripts/code-repair-agent.mjs --ci
- name: Re-check build after repair
id: rebuild
continue-on-error: true
run: npx tsc --noEmit
- name: Commit fixes
if: steps.build.outcome == 'failure' && steps.rebuild.outcome == 'success'
run: |
git config user.name "CRM Repair Bot"
git config user.email "bot@coastit.co.za"
git add -A
git diff --cached --quiet || git commit -m "[bot] Auto-repair build errors"
git push
- name: Build failed — report
if: steps.rebuild.outcome == 'failure'
run: |
echo "::error::Build still failing after auto-repair. Manual intervention required."
exit 1
+9 -3
View File
@@ -47,9 +47,6 @@ 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__/
@@ -58,3 +55,12 @@ browser-use-service/.env
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.git
.git_bak
node_modules
target
# rust build artifacts
rust-ai/target/
# browser-use-service generated files
browser-use-service/*.txt
+42
View File
@@ -0,0 +1,42 @@
{
"version": 2,
"description": "Template registry for self-healing setup. Maps internal import paths to templates.",
"templates": {
"data/stickers": {
"template": "stickers.ts",
"description": "Emoji-based sticker packs for media picker"
},
"data/constants": {
"template": "constants.ts",
"description": "App-wide constants (lead statuses, roles, event types)"
},
"lib/utils": {
"template": "utils.ts",
"description": "Utility functions (cn, formatters, helpers)"
},
"types/index": {
"template": "types-index.ts",
"description": "Core type definitions (User, Lead, DashboardStats)"
},
"hooks/use-media": {
"template": "hooks/use-media.ts",
"description": "Media query hook"
},
"hooks/use-debounce": {
"template": "hooks/use-debounce.ts",
"description": "Debounce hook"
},
"hooks/use-local-storage": {
"template": "hooks/use-local-storage.ts",
"description": "LocalStorage hook with SSR safety"
}
},
"fallbackTemplates": {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts"
}
}
@@ -0,0 +1,7 @@
// ── Generic Component Template ────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/components/* imports. Customize as needed.
export default function GenericComponent() {
return <div />;
}
+54
View File
@@ -0,0 +1,54 @@
// ── App Constants ────────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/constants.ts to customize.
export const APP_NAME = "CoastIT CRM";
export const APP_VERSION = "1.0.0";
export const LEAD_STATUSES = {
new: { label: "New", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-yellow-500" },
qualified: { label: "Qualified", color: "bg-purple-500" },
proposal: { label: "Proposal", color: "bg-orange-500" },
won: { label: "Won", color: "bg-green-500" },
lost: { label: "Lost", color: "bg-red-500" },
} as const;
export const USER_ROLES = {
super_admin: { label: "Super Admin", level: 1 },
admin: { label: "Admin", level: 2 },
sales: { label: "Sales", level: 3 },
dev: { label: "Developer", level: 4 },
} as const;
export const EVENT_TYPES = [
"meeting",
"call",
"email",
"task",
"note",
] as const;
export const EVENT_STATUSES = [
"scheduled",
"completed",
"cancelled",
"rescheduled",
] as const;
export const AI_MODELS = {
chat: "llama3.2:3b",
scraper: "dolphin-llama3:8b",
coder: "qwen2.5-coder:1.5b-base",
} as const;
export const PAGINATION = {
defaultLimit: 50,
maxLimit: 200,
} as const;
export const DATE_FORMATS = {
short: "MMM d, yyyy",
long: "MMMM d, yyyy",
time: "h:mm a",
datetime: "MMM d, yyyy h:mm a",
} as const;
@@ -0,0 +1,17 @@
// ── Generic Data Module Template ─────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/data/* imports. Customize as needed.
export interface DataItem {
id: string;
name: string;
[key: string]: unknown;
}
export function getDataItems(): DataItem[] {
return [];
}
export function getDataItem(id: string): DataItem | undefined {
return undefined;
}
@@ -0,0 +1,10 @@
// ── Generic Hook Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/hooks/* imports. Customize as needed.
import { useState, useEffect } from "react";
export function useHook<T>(initialValue: T): [T, (value: T) => void] {
const [value, setValue] = useState(initialValue);
return [value, setValue];
}
@@ -0,0 +1,15 @@
// ── useDebounce Hook ──────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
@@ -0,0 +1,29 @@
// ── useLocalStorage Hook ──────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(initialValue);
useEffect(() => {
try {
const item = window.localStorage.getItem(key);
if (item) setStoredValue(JSON.parse(item));
} catch {
// corrupted data — use initial value
}
}, [key]);
const setValue = (value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch {
// storage full or disabled
}
};
return [storedValue, setValue];
}
@@ -0,0 +1,18 @@
// ── useMedia Hook ─────────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useMedia(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
@@ -0,0 +1,7 @@
// ── Generic Lib Module Template ──────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/lib/* imports. Customize as needed.
export function libFunction(): void {
// Placeholder
}
+95
View File
@@ -0,0 +1,95 @@
// ── Sticker Packs ─────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
export interface Sticker {
id: string;
name: string;
emoji?: string;
svg?: string;
}
export interface StickerPack {
id: string;
name: string;
stickers: Sticker[];
}
const reactionStickers: Sticker[] = [
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
{ id: "heart", name: "Heart", emoji: "❤️" },
{ id: "laugh", name: "Laugh", emoji: "😂" },
{ id: "wow", name: "Wow", emoji: "😮" },
{ id: "sad", name: "Sad", emoji: "😢" },
{ id: "angry", name: "Angry", emoji: "😡" },
{ id: "clap", name: "Clap", emoji: "👏" },
{ id: "fire", name: "Fire", emoji: "🔥" },
{ id: "100", name: "100", emoji: "💯" },
{ id: "eyes", name: "Eyes", emoji: "👀" },
{ id: "pray", name: "Pray", emoji: "🙏" },
];
const animalStickers: Sticker[] = [
{ id: "cat", name: "Cat", emoji: "🐱" },
{ id: "dog", name: "Dog", emoji: "🐶" },
{ id: "panda", name: "Panda", emoji: "🐼" },
{ id: "fox", name: "Fox", emoji: "🦊" },
{ id: "bear", name: "Bear", emoji: "🐻" },
{ id: "koala", name: "Koala", emoji: "🐨" },
{ id: "tiger", name: "Tiger", emoji: "🐯" },
{ id: "lion", name: "Lion", emoji: "🦁" },
{ id: "monkey", name: "Monkey", emoji: "🐵" },
{ id: "frog", name: "Frog", emoji: "🐸" },
{ id: "penguin", name: "Penguin", emoji: "🐧" },
{ id: "bird", name: "Bird", emoji: "🐦" },
];
const foodStickers: Sticker[] = [
{ id: "pizza", name: "Pizza", emoji: "🍕" },
{ id: "burger", name: "Burger", emoji: "🍔" },
{ id: "taco", name: "Taco", emoji: "🌮" },
{ id: "sushi", name: "Sushi", emoji: "🍣" },
{ id: "ramen", name: "Ramen", emoji: "🍜" },
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
{ id: "cake", name: "Cake", emoji: "🍰" },
{ id: "coffee", name: "Coffee", emoji: "☕" },
{ id: "beer", name: "Beer", emoji: "🍺" },
{ id: "wine", name: "Wine", emoji: "🍷" },
{ id: "donut", name: "Donut", emoji: "🍩" },
{ id: "cookie", name: "Cookie", emoji: "🍪" },
];
const activityStickers: Sticker[] = [
{ id: "football", name: "Football", emoji: "⚽" },
{ id: "basketball", name: "Basketball", emoji: "🏀" },
{ id: "tennis", name: "Tennis", emoji: "🎾" },
{ id: "running", name: "Running", emoji: "🏃" },
{ id: "swimming", name: "Swimming", emoji: "🏊" },
{ id: "cycling", name: "Cycling", emoji: "🚴" },
{ id: "gym", name: "Gym", emoji: "🏋️" },
{ id: "yoga", name: "Yoga", emoji: "🧘" },
{ id: "music", name: "Music", emoji: "🎵" },
{ id: "party", name: "Party", emoji: "🎉" },
{ id: "gift", name: "Gift", emoji: "🎁" },
{ id: "trophy", name: "Trophy", emoji: "🏆" },
];
const stickerPacks: StickerPack[] = [
{ id: "reactions", name: "Reactions", stickers: reactionStickers },
{ id: "animals", name: "Animals", stickers: animalStickers },
{ id: "food", name: "Food & Drink", stickers: foodStickers },
{ id: "activities", name: "Activities", stickers: activityStickers },
];
export function getStickerPacks(): StickerPack[] {
return stickerPacks;
}
export function getStickerPack(id: string): StickerPack | undefined {
return stickerPacks.find((p) => p.id === id);
}
export function getSticker(packId: string, stickerId: string): Sticker | undefined {
const pack = getStickerPack(packId);
return pack?.stickers.find((s) => s.id === stickerId);
}
@@ -0,0 +1,10 @@
// ── Generic Types Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/types/* imports. Customize as needed.
export type GenericType = Record<string, unknown>;
export interface GenericInterface {
id: string;
[key: string]: unknown;
}
+141
View File
@@ -0,0 +1,141 @@
// ── Type Definitions ──────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/types-index.ts to customize.
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;
createdAt: string;
}
export interface Lead {
id: string;
companyName: string;
contactName: string;
email: string;
phone: string;
source: string;
description: string;
status: LeadStatus;
assignedUserId: string | null;
assignedUser: User | null;
createdAt: string;
updatedAt: string;
}
export type LeadStatus =
| "new"
| "contacted"
| "qualified"
| "proposal"
| "won"
| "lost";
export interface DashboardStats {
totalLeads: number;
newLeads: number;
contactedLeads: number;
qualifiedLeads: number;
proposalLeads: number;
wonLeads: number;
lostLeads: number;
conversionRate: number;
monthlyBreakdown: {
label: string;
total: number;
new: number;
contacted: number;
qualified: number;
proposal: number;
won: number;
lost: number;
}[];
leadsPerMonth: { label: string; leads: number; won: number }[];
recentLeads: Lead[];
statusDistribution: { name: string; value: number; color: string }[];
periodLabel: string;
trends: Record<string, { pct: number; up: boolean }>;
}
export interface Note {
id: string;
leadId: string;
userId: string;
authorName: string;
authorAvatar: string;
authorRole: UserRole;
note: string;
createdAt: string;
updatedAt: string;
}
export type NotificationType =
| "lead_created"
| "lead_status_changed"
| "lead_assigned"
| "chat_message"
| "note_added"
| "event_scheduled";
export interface Notification {
id: string;
type: NotificationType;
title: string;
description: string;
timestamp: string;
read: boolean;
link?: string;
}
export interface ChatMessage {
id: string;
conversationId: string;
senderId: string;
senderName: string;
senderAvatar: string;
content: string;
timestamp: string;
}
export interface Conversation {
id: string;
participants: { id: string; name: string; avatar: string; role: string }[];
lastMessage: string;
lastMessageTime: string;
unread: number;
messages: ChatMessage[];
}
export type EventType = "meeting" | "call" | "email" | "task" | "note";
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
export interface CalendarEvent {
id: string;
userId: string;
participantId: string | null;
developerId: string | null;
leadId: string | null;
conversationId: string | null;
title: string;
description: string | null;
participantNotes: string | null;
eventType: EventType;
startTime: string;
endTime: string | null;
durationMinutes: number | null;
status: EventStatus;
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
lead: { id: string; companyName: string; contactName: string } | null;
clientName: string | null;
clientEmail: string | null;
clientPhone: string | null;
createdAt: string;
}
@@ -0,0 +1,7 @@
// ── Generic Utils Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/utils/* imports. Customize as needed.
export function utilsFunction(): void {
// Placeholder
}
+76
View File
@@ -0,0 +1,76 @@
// ── Utility Functions ────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/utils.ts to customize.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
...options,
});
}
export function formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length - 3) + "...";
}
export function generateId(): string {
return crypto.randomUUID();
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
export function classNames(...classes: (string | boolean | undefined | null)[]): string {
return classes.filter(Boolean).join(" ");
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function retry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
return fn().catch((err) =>
retries > 0 ? sleep(delay).then(() => retry(fn, retries - 1, delay * 2)) : Promise.reject(err)
);
}
+23
View File
@@ -0,0 +1,23 @@
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SCRAPER_URL=http://localhost:3008
ENV NEXT_PUBLIC_SCRAPER_URL=$NEXT_PUBLIC_SCRAPER_URL
RUN npm run build
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.ts ./next.config.ts
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3006
CMD ["./node_modules/.bin/next", "start", "-p", "3006"]
+7
View File
@@ -0,0 +1,7 @@
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY signaling-server.mjs ./
EXPOSE 3007
CMD ["node", "signaling-server.mjs"]
+35
View File
@@ -0,0 +1,35 @@
/* ============================================================================
Default Theme — Clean, Professional SaaS Design Tokens
This is the standard CRM appearance. No Spidey branding or assets.
============================================================================ */
.theme-default {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 80% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 215 25% 22%;
--input: 215 25% 22%;
--ring: 210 80% 52%;
--radius: 0.5rem;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 210 80% 52%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 215 25% 22%;
--sidebar-ring: 210 80% 52%;
}
+88
View File
@@ -0,0 +1,88 @@
/* ============================================================================
Spidey Theme — Baseline Design Tokens
This is the current website appearance captured as a reusable CSS theme.
============================================================================ */
.theme-spidey {
--background: 240 18% 97%;
--foreground: 240 8% 14%;
--card: 0 0% 100%;
--card-foreground: 240 8% 14%;
--popover: 0 0% 100%;
--popover-foreground: 240 8% 14%;
--secondary: 220 75% 48%;
--secondary-foreground: 0 0% 100%;
--muted: 240 8% 92%;
--muted-foreground: 240 5% 50%;
--accent: 220 15% 90%;
--accent-foreground: 220 60% 28%;
--destructive: 0 70% 46%;
--destructive-foreground: 0 0% 100%;
--border: 240 8% 84%;
--input: 240 8% 84%;
--radius: 0.5rem;
--sidebar: 0 0% 99%;
--sidebar-foreground: 240 8% 14%;
--sidebar-accent: 220 20% 93%;
--sidebar-accent-foreground: 220 70% 28%;
--sidebar-border: 240 8% 84%;
}
.dark.theme-spidey {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--radius: 0.5rem;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
}
.theme-spidey body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
repeating-conic-gradient(
from 10deg,
transparent 0deg 10deg,
rgba(220, 38, 38, 0.04) 10deg 12deg
),
repeating-radial-gradient(
circle at 50% 50%,
transparent 0,
transparent 30px,
rgba(37, 99, 235, 0.03) 30px,
transparent 32px
),
radial-gradient(2px 2px at 10% 20%, rgba(220,38,38,0.35) 0%, transparent 100%),
radial-gradient(2px 2px at 30% 80%, rgba(37,99,235,0.35) 0%, transparent 100%),
radial-gradient(2px 2px at 50% 30%, rgba(220,38,38,0.3) 0%, transparent 100%),
radial-gradient(2px 2px at 70% 60%, rgba(37,99,235,0.3) 0%, transparent 100%),
radial-gradient(2px 2px at 85% 15%, rgba(220,38,38,0.35) 0%, transparent 100%),
radial-gradient(2px 2px at 20% 50%, rgba(37,99,235,0.25) 0%, transparent 100%),
radial-gradient(2px 2px at 65% 85%, rgba(220,38,38,0.25) 0%, transparent 100%),
radial-gradient(2px 2px at 40% 10%, rgba(37,99,235,0.3) 0%, transparent 100%),
radial-gradient(2px 2px at 90% 75%, rgba(220,38,38,0.25) 0%, transparent 100%),
radial-gradient(2px 2px at 5% 60%, rgba(37,99,235,0.3) 0%, transparent 100%),
radial-gradient(2px 2px at 55% 55%, rgba(220,38,38,0.2) 0%, transparent 100%);
background-size: 200% 200%;
animation: drift 60s linear infinite;
}
+52
View File
@@ -0,0 +1,52 @@
{
"theme": {
"id": "spidey",
"name": "Spidey",
"description": "Current website appearance — dark theme with red accents",
"type": "dark",
"default": true
},
"colors": {
"background": "hsl(222.2, 84%, 4.9%)",
"foreground": "hsl(210, 40%, 98%)",
"card": "hsl(222.2, 84%, 4.9%)",
"cardForeground": "hsl(210, 40%, 98%)",
"popover": "hsl(222.2, 84%, 4.9%)",
"popoverForeground": "hsl(210, 40%, 98%)",
"primary": "hsl(0, 100%, 53%)",
"primaryForeground": "hsl(222.2, 47.4%, 11.2%)",
"secondary": "hsl(217.2, 32.6%, 17.5%)",
"secondaryForeground": "hsl(210, 40%, 98%)",
"muted": "hsl(217.2, 32.6%, 17.5%)",
"mutedForeground": "hsl(215, 20.2%, 65.1%)",
"accent": "hsl(217.2, 32.6%, 17.5%)",
"accentForeground": "hsl(210, 40%, 98%)",
"destructive": "hsl(0, 62.8%, 30.6%)",
"destructiveForeground": "hsl(210, 40%, 98%)",
"border": "hsl(217.2, 32.6%, 17.5%)",
"input": "hsl(217.2, 32.6%, 17.5%)",
"ring": "hsl(0, 100%, 53%)",
"sidebar": "hsl(222.2, 84%, 4.9%)",
"sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarPrimary": "hsl(0, 100%, 53%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(217.2, 32.6%, 17.5%)",
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)",
"sidebarRing": "hsl(0, 100%, 53%)"
},
"borderRadius": "0.5rem",
"typography": {
"fontFamily": "Inter, sans-serif",
"headingFont": "Inter, sans-serif"
},
"keyColors": {
"primary": "#FF0000",
"background": "#0a0a0f",
"card": "#141414",
"sidebar": "#0a0a0f",
"border": "#1a1a24",
"text": "#e8e8ef",
"mutedText": "#888888"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT u.id, u.first_name, u.last_name, u.email
FROM users u
WHERE u.deleted_at IS NULL AND u.id != $1
ORDER BY u.first_name ASC`,
[user.id],
)
const users = result.rows.map((r: any) => ({
id: r.id,
name: `${r.first_name} ${r.last_name}`,
email: r.email,
}))
return NextResponse.json({ users })
} catch (error) {
console.error("Event users error:", error)
return NextResponse.json({ error: "Failed to load users" }, { status: 500 })
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { buildIcs } from "@/lib/ics"
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const result = await query(
`SELECT e.id, e.user_id, e.participant_id, e.title, e.description, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
u.email AS user_email, u.first_name AS user_first, u.last_name AS user_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
WHERE e.id = $1 AND e.user_id = $2`,
[id, user.id],
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const r = result.rows[0]
const icsContent = buildIcs({
uid: r.id,
title: r.title,
description: r.description || undefined,
startTime: new Date(r.start_time),
endTime: r.end_time ? new Date(r.end_time) : undefined,
durationMinutes: r.duration_minutes || undefined,
organizerName: `${r.user_first} ${r.user_last}`,
organizerEmail: r.user_email,
attendeeName: r.p_first ? `${r.p_first} ${r.p_last}` : undefined,
attendeeEmail: r.p_email || undefined,
})
return new NextResponse(icsContent, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": `attachment; filename="event-${r.id}.ics"`,
},
})
} catch (error) {
console.error("ICS GET error:", error)
return NextResponse.json({ error: "Failed to generate calendar file" }, { status: 500 })
}
}
+167
View File
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventRescheduled } from "@/lib/email"
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = await request.json()
const existing = await query(
`SELECT e.user_id, e.participant_id, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
u.email AS u_email, u.first_name AS u_first, u.last_name AS u_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
WHERE e.id = $1`,
[id],
user.id,
)
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const old = existing.rows[0]
const isCreator = old.user_id === user.id
const isParticipant = old.participant_id === user.id
if (!isCreator && !isParticipant) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
if (!isCreator && (
(title !== undefined && title !== old.title) ||
(eventType !== undefined && eventType !== old.event_type) ||
(startTime !== undefined && startTime !== old.start_time) ||
(endTime !== undefined && endTime !== old.end_time) ||
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
(participantId !== undefined && participantId !== old.participant_id)
)) {
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
}
const result = await query(
`UPDATE scheduled_events SET
title = COALESCE($1, title),
description = COALESCE($2, description),
participant_notes = COALESCE($3, participant_notes),
event_type = COALESCE($4, event_type),
start_time = COALESCE($5, start_time),
end_time = COALESCE($6, end_time),
duration_minutes = COALESCE($7, duration_minutes),
status = COALESCE($8, status),
participant_id = COALESCE($9, participant_id),
updated_at = NOW()
WHERE id = $10
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
[
title || null,
description ?? null,
participantNotes ?? null,
eventType || null,
startTime || null,
endTime ?? null,
durationMinutes ?? null,
status || null,
participantId ?? null,
id,
],
user.id,
)
const r = result.rows[0]
const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time ||
r.title !== old.title || r.event_type !== old.event_type ||
r.status !== old.status
if (isChanged && r.participant_id && r.participant_id !== user.id) {
sendEventRescheduled({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
participantName: old.p_first ? `${old.p_first} ${old.p_last}` : null,
participantEmail: old.p_email || null,
title: r.title,
description: r.description || undefined,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time || undefined,
durationMinutes: r.duration_minutes || undefined,
}).catch((err) => console.error("Reschedule email error:", err))
}
const creatorResult = await query(
`SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url,
ur.role_id, r.name AS role_name, r.display_name AS role_display
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.id = $1`,
[old.user_id],
)
const creatorInfo = creatorResult.rows[0]
const participantInfo = old.participant_id ? { id: old.participant_id, name: `${old.p_first} ${old.p_last}` || old.participant_id, email: old.p_email || null } : null
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: creatorInfo ? { id: creatorInfo.id, name: `${creatorInfo.first_name} ${creatorInfo.last_name}`, email: creatorInfo.email, role: creatorInfo.role_display || creatorInfo.role_name, avatar: creatorInfo.avatar_url } : { id: old.user_id, name: "Unknown" },
participant: participantInfo,
lead: null,
createdAt: r.created_at,
},
})
} catch (error) {
console.error("Events PATCH error:", error)
return NextResponse.json({ error: "Failed to update event" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const existing = await query(
`SELECT user_id FROM scheduled_events WHERE id = $1`,
[id],
user.id,
)
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
if (existing.rows[0].user_id !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
await query(`DELETE FROM scheduled_events WHERE id = $1`, [id], user.id)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Events DELETE error:", error)
return NextResponse.json({ error: "Failed to delete event" }, { status: 500 })
}
}
+188
View File
@@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventConfirmation } from "@/lib/email"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const start = searchParams.get("start")
const end = searchParams.get("end")
const status = searchParams.get("status")
let sql = `SELECT e.id, e.user_id, e.participant_id, e.lead_id, e.conversation_id,
e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
u.id AS u_id, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.avatar_url AS u_avatar,
ur.role_id AS u_role_id, r.name AS u_role_name, r.display_name AS u_role_display,
p.id AS p_id, p.first_name AS p_first, p.last_name AS p_last, p.email AS p_email, p.avatar_url AS p_avatar,
pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display,
l.id AS l_id, l.company_name, l.contact_name
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
LEFT JOIN leads l ON l.id = e.lead_id
LEFT JOIN user_roles ur ON ur.user_id = e.user_id
LEFT JOIN roles r ON r.id = ur.role_id
LEFT JOIN user_roles pr ON pr.user_id = e.participant_id
LEFT JOIN roles pr2 ON pr2.id = pr.role_id`
const params: unknown[] = []
let idx = 1
if (user.role !== "super_admin") {
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}`
params.push(user.id)
idx++
} else {
sql += ` WHERE 1=1`
}
if (start) {
sql += ` AND e.start_time >= $${idx}`
params.push(start)
idx++
}
if (end) {
sql += ` AND e.start_time <= $${idx}`
params.push(end)
idx++
}
if (status) {
sql += ` AND e.status = $${idx}`
params.push(status)
idx++
}
sql += " ORDER BY e.start_time ASC"
const result = await query(sql, params, user.id)
const events = result.rows.map((r: any) => ({
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: { id: r.u_id, name: `${r.u_first} ${r.u_last}`, email: r.u_email, role: r.u_role_display || r.u_role_name, avatar: r.u_avatar },
participant: r.p_id ? { id: r.p_id, name: `${r.p_first} ${r.p_last}`, email: r.p_email, role: r.p_role_display || r.p_role_name, avatar: r.p_avatar } : null,
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
createdAt: r.created_at,
}))
return NextResponse.json({ events })
} catch (error) {
console.error("Events GET error:", error)
return NextResponse.json({ error: "Failed to load events" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const {
participantId, leadId, conversationId,
title, description, eventType,
startTime, endTime, durationMinutes,
} = await request.json()
if (!title || !startTime) {
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 })
}
const result = await query(
`INSERT INTO scheduled_events (user_id, participant_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
[
user.id,
participantId || null,
leadId || null,
conversationId || null,
title,
description || null,
eventType || "meeting",
startTime,
endTime || null,
durationMinutes || null,
],
user.id,
)
const r = result.rows[0]
if (participantId && participantId !== user.id) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
participantId,
"event_scheduled",
`Meeting scheduled: ${title}`,
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
`/calendar`,
r.id,
"scheduled_event",
],
)
}
const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId],
) : null
const participant = participantResult?.rows[0] || null
sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
participantName: participant ? `${participant.first_name} ${participant.last_name}` : null,
participantEmail: participant?.email || null,
title,
description: description || undefined,
eventType: eventType || "meeting",
startTime,
endTime: endTime || undefined,
durationMinutes: durationMinutes || undefined,
}).catch((err) => console.error("Email error:", err))
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
createdAt: r.created_at,
},
}, { status: 201 })
} catch (error) {
console.error("Events POST error:", error)
return NextResponse.json({ error: "Failed to create event" }, { status: 500 })
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS scheduled_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
participant_id UUID REFERENCES users(id) ON DELETE SET NULL,
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
event_type VARCHAR(20) NOT NULL DEFAULT 'meeting',
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
duration_minutes INT,
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_event_type CHECK (event_type IN ('meeting','call','chat','follow_up','demo','other')),
CONSTRAINT chk_event_status CHECK (status IN ('scheduled','completed','cancelled','rescheduled'))
);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user ON scheduled_events(user_id, start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_participant ON scheduled_events(participant_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_lead ON scheduled_events(lead_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_date ON scheduled_events(start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_status ON scheduled_events(user_id, status);
@@ -0,0 +1,22 @@
-- ============================================================================
-- Migration 013: Row-Level Security for scheduled_events
-- ============================================================================
-- Enables RLS on the scheduled_events table so that users can only see
-- their own events at the database level. This is defense-in-depth:
-- the API already filters by user_id, but RLS ensures data isolation
-- even if there's a bug in the application layer.
--
-- The policy allows all operations when app.current_user_id is NULL
-- (backward-compatible fallback for queries that don't set the variable).
-- When the variable IS set, only rows matching the user's ID are visible.
-- ============================================================================
ALTER TABLE scheduled_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1,19 @@
-- ============================================================================
-- Migration 015: Fix RLS for scheduled_events to include participant_id
-- ============================================================================
-- Previously, the RLS policy only checked user_id, which meant participants
-- could not see events at the database level (the app-layer WHERE clause
-- did the filtering, but RLS was defense-in-depth that missed this case).
--
-- This policy also allows app.current_user_id IS NULL for backward compat
-- with queries that don't set the session variable.
-- ============================================================================
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR participant_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1 @@
ALTER TABLE scheduled_events ADD COLUMN IF NOT EXISTS participant_notes TEXT;
+36
View File
@@ -0,0 +1,36 @@
// ── Web Calendar Types ──────────────────────────────────────────────
// Core calendar event types used across the CRM system, including
// event classification, status tracking, and participant metadata.
/** Possible types of calendar events — triggered by different CRM workflows. */
export type EventType = "call" | "follow_up" | "website_creation"
/** Lifecycle state of a calendar event. */
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
/** A single calendar entry linking a user, participant, developer, and optional lead/conversation. */
export interface CalendarEvent {
id: string
userId: string
participantId: string | null
developerId: string | null
leadId: string | null
conversationId: string | null
title: string
description: string | null
participantNotes: string | null
eventType: EventType
startTime: string
endTime: string | null
durationMinutes: number | null
status: EventStatus
// Denormalized user references for display (avoid extra joins in list views)
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
lead: { id: string; companyName: string; contactName: string } | null
clientName: string | null
clientEmail: string | null
clientPhone: string | null
createdAt: string
}
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-slim
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY ai-server/ ./ai-server/
COPY splash.html ./
COPY data/ ./data/
COPY scripts/run-migrations.mjs ./scripts/run-migrations.mjs
COPY database/ ./database/
EXPOSE 3001
CMD ["node", "ai-server/index.mjs"]
+260 -42
View File
@@ -1,12 +1,27 @@
// ── CRM AI Server ──────────────────────────────────────────────────
// Provides:
// - Chat API (POST /ai/chat) — routes user messages to Ollama for sales coaching
// - Setup wizard endpoints (GET /setup/status, POST /setup/profile, etc.)
// - Combined /status endpoint for splash page health polling
// - Configuration routes (GET/POST /ai/instructions, GET /ai/jobs)
// - Model pull support (POST /setup/ollama/pull)
//
// This is a zero-dependency Node.js HTTP server (no Express needed).
import http from "node:http" import http from "node:http"
import fs from "node:fs" import fs from "node:fs"
import path from "node:path" import path from "node:path"
import { spawn } from "node:child_process"
import crypto from "node:crypto"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.resolve(__dirname, "..") const ROOT = path.resolve(__dirname, "..")
// ── Load .env.local ────────────────────────────────────────────── // ── Load .env.local ──────────────────────────────────────────────
// Reads key=value pairs and sets them as process.env so they're
// available throughout the server. Ignores comments and blank lines.
// Values with matching quotes are unquoted.
try { try {
const envPath = path.join(ROOT, ".env.local") const envPath = path.join(ROOT, ".env.local")
const envContent = fs.readFileSync(envPath, "utf-8") const envContent = fs.readFileSync(envPath, "utf-8")
@@ -22,7 +37,7 @@ try {
} }
console.log("Loaded .env.local") console.log("Loaded .env.local")
} catch { } catch {
// .env.local may not exist, ignore // .env.local may not exist (first run), which is fine
} }
// ── Config from env ───────────────────────────────────────────── // ── Config from env ─────────────────────────────────────────────
@@ -30,11 +45,22 @@ const PORT = parseInt(process.env.AI_PORT || "3001", 10)
const HOST = process.env.AI_HOST || "0.0.0.0" const HOST = process.env.AI_HOST || "0.0.0.0"
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434" const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
const MODEL = process.env.AI_MODEL || "llama3.2:3b" const MODEL = process.env.AI_MODEL || "llama3.2:3b"
const SCRAPER_URL = process.env.SCRAPER_URL || "http://127.0.0.1:3008"
const FRONTEND_URL = process.env.FRONTEND_URL || "http://127.0.0.1:3006"
const DATABASE_URL = process.env.DATABASE_URL const DATABASE_URL = process.env.DATABASE_URL
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl") 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") const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
// ── Setup state ──────────────────────────────────────────────────
// Tracks the Ollama model pull process so the setup wizard can
// poll for download progress.
let pullProcess = null
let pullProgress = { status: "idle", progress: 0, message: "" }
// ── Job loading ───────────────────────────────────────────────── // ── Job loading ─────────────────────────────────────────────────
// Loads job categories from a JSONL file (one JSON object per line).
// Used as context for the AI sales coach chat responses.
/** Load job categories from the JSONL file at JOBS_PATH. Returns an array of parsed job objects. */
function loadJobs() { function loadJobs() {
try { try {
const content = fs.readFileSync(JOBS_PATH, "utf-8") const content = fs.readFileSync(JOBS_PATH, "utf-8")
@@ -59,6 +85,9 @@ function loadJobs() {
} }
// ── ai.md management ──────────────────────────────────────────── // ── ai.md management ────────────────────────────────────────────
// ai.md is a Markdown file containing system instructions for the AI.
// It can be read, written, or appended to via the API.
/** Read the full contents of ai.md. Returns empty string if the file doesn't exist yet. */
function readInstructions() { function readInstructions() {
try { try {
return fs.readFileSync(AI_MD_PATH, "utf-8") return fs.readFileSync(AI_MD_PATH, "utf-8")
@@ -67,12 +96,15 @@ function readInstructions() {
} }
} }
/** Overwrite ai.md with new content. Returns the content that was written. */
function writeInstructions(content) { function writeInstructions(content) {
fs.writeFileSync(AI_MD_PATH, content, "utf-8") fs.writeFileSync(AI_MD_PATH, content, "utf-8")
return content return content
} }
/** Append a timestamped entry to the ## Improvement Log section of ai.md. Creates the section if it doesn't exist. */
function appendToImprovementLog(entry) { function appendToImprovementLog(entry) {
// Adds a timestamped entry to the ## Improvement Log section of ai.md
const current = readInstructions() const current = readInstructions()
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16) const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
const logEntry = `\n- ${timestamp}${entry}` const logEntry = `\n- ${timestamp}${entry}`
@@ -96,29 +128,36 @@ function appendToImprovementLog(entry) {
} }
// ── Chat handler ──────────────────────────────────────────────── // ── Chat handler ────────────────────────────────────────────────
// scrapeFacebook() calls the scraper service (port 3008) to get leads.
// handleChat() processes user messages — triggers lead scraping when
// the user asks for "leads" or "listings", otherwise routes to Ollama
// for AI-powered sales coaching.
async function scrapeFacebook() { async function scrapeFacebook() {
const profilePath = process.env.FX_PROFILE || "" const profilePath = process.env.FX_PROFILE || ""
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}` 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 { try {
const body = await new Promise((resolve, reject) => { 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) => { const parsed = new URL(SCRAPER_URL)
let done = false
const req = http.request({ hostname: parsed.hostname, port: parsed.port || 3008, path: urlPath, method: "POST", timeout: 60000 }, (res) => {
let data = "" let data = ""
res.on("data", (c) => data += c) res.on("data", (c) => data += c)
res.on("end", () => resolve(data)) res.on("end", () => { done = true; resolve(data) })
res.on("error", reject) res.on("error", (e) => { if (!done) { done = true; reject(e) } })
}) })
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) }) req.on("timeout", () => { if (!done) { done = true; req.destroy(); reject(new Error("scraper timeout")) } })
req.on("error", reject) req.on("error", (e) => { if (!done) { done = true; reject(e) } })
req.end() req.end()
}) })
const data = JSON.parse(body) const data = JSON.parse(body)
return data return data
} catch (e) { } catch (e) {
console.error("scrapeFacebook error:", e.message)
return null return null
} }
} }
/** Format scraped Facebook leads into a human-readable Markdown string for the AI chat response. */
function formatLeads(leads) { function formatLeads(leads) {
if (!leads || leads.length === 0) return "No leads found from the latest scrape." if (!leads || leads.length === 0) return "No leads found from the latest scrape."
let output = `**${leads.length} leads found:**\n\n` let output = `**${leads.length} leads found:**\n\n`
@@ -134,6 +173,7 @@ function formatLeads(leads) {
} }
async function handleChat(userMessage, userId, userRole) { async function handleChat(userMessage, userId, userRole) {
// If the user asks for leads, trigger the scraper
const lowerMsg = userMessage.toLowerCase() const lowerMsg = userMessage.toLowerCase()
const triggerWords = ["lists", "listings", "leads", "recent leads", "pull leads", "show me leads", "show listings"] const triggerWords = ["lists", "listings", "leads", "recent leads", "pull leads", "show me leads", "show listings"]
@@ -145,6 +185,7 @@ async function handleChat(userMessage, userId, userRole) {
return "Scraper returned no results or encountered an error. Try again later." return "Scraper returned no results or encountered an error. Try again later."
} }
// Otherwise, build a system prompt with job context and send to Ollama
const jobs = loadedJobs const jobs = loadedJobs
const instructions = readInstructions() const instructions = readInstructions()
@@ -165,6 +206,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, { const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(60000),
body: JSON.stringify({ body: JSON.stringify({
model: MODEL, model: MODEL,
messages: [ messages: [
@@ -184,7 +226,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego
const data = await ollamaRes.json() const data = await ollamaRes.json()
const responseText = data.message?.content || "" const responseText = data.message?.content || ""
// Try to persist to DB (best-effort) // Persist conversation to PostgreSQL (best-effort — table may not exist yet)
try { try {
if (pgPool && userId) { if (pgPool && userId) {
await pgPool.query( await pgPool.query(
@@ -200,6 +242,9 @@ Provide concise, actionable sales advice. When asked about a specific job catego
} }
// ── PG pool (lazy init) ──────────────────────────────────────── // ── PG pool (lazy init) ────────────────────────────────────────
// PostgreSQL connection pool for storing conversation history.
// Lazy-initialized so the server starts even without a DB.
/** Lazy-initialize the PostgreSQL connection pool. Non-blocking — server works without a database. */
let pgPool = null let pgPool = null
async function initPg() { async function initPg() {
if (!DATABASE_URL) return if (!DATABASE_URL) return
@@ -216,11 +261,13 @@ async function initPg() {
// ── Request router ───────────────────────────────────────────── // ── Request router ─────────────────────────────────────────────
const loadedJobs = loadJobs() const loadedJobs = loadJobs()
/** Write a JSON response with the given HTTP status code. */
function sendJSON(res, status, data) { function sendJSON(res, status, data) {
res.writeHead(status, { "Content-Type": "application/json" }) res.writeHead(status, { "Content-Type": "application/json" })
res.end(JSON.stringify(data)) res.end(JSON.stringify(data))
} }
/** Parse the JSON body of an incoming HTTP request. */
function parseBody(req) { function parseBody(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let body = "" let body = ""
@@ -236,13 +283,15 @@ function parseBody(req) {
}) })
} }
/** Parse a request URL into its pathname and search params. Falls back to localhost if no Host header is present. */
function parseURL(req) { function parseURL(req) {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`) const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
return { pathname: url.pathname, searchParams: url.searchParams } return { pathname: url.pathname, searchParams: url.searchParams }
} }
// ── HTTP Server ─────────────────────────────────────────────────
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
// CORS // CORS headers — allow the Next.js frontend (port 3006) to call us
res.setHeader("Access-Control-Allow-Origin", "*") res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type") res.setHeader("Access-Control-Allow-Headers", "Content-Type")
@@ -275,13 +324,16 @@ const server = http.createServer(async (req, res) => {
} }
// GET /status — combined health of all services // GET /status — combined health of all services
// Used by the splash page to check if AI, Scraper, and Frontend are ready.
// Polls each service internally to avoid cross-origin CORS issues.
if (req.method === "GET" && pathname === "/status") { if (req.method === "GET" && pathname === "/status") {
const { default: http } = await import("http") const { default: http } = await import("http")
const results = { ai: true } const results = { ai: true }
// Check scraper // Check scraper
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() }) const r = http.get(`${SCRAPER_URL}/health`, { timeout: 3000 }, (res) => { res.resume(); resolve() })
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
r.on("error", reject) r.on("error", reject)
}) })
results.scraper = true results.scraper = true
@@ -289,7 +341,8 @@ const server = http.createServer(async (req, res) => {
// Check frontend // Check frontend
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() }) const r = http.get(FRONTEND_URL, { timeout: 3000 }, (res) => { res.resume(); resolve() })
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
r.on("error", reject) r.on("error", reject)
}) })
results.frontend = true results.frontend = true
@@ -297,18 +350,193 @@ const server = http.createServer(async (req, res) => {
return sendJSON(res, 200, results) return sendJSON(res, 200, results)
} }
// GET /ai/jobs // ── Setup endpoints ─────────────────────────────────────────
// GET /setup/status — check environment
// Called by the splash page on boot. Returns info about:
// - Ollama availability
// - Model presence
// - Detected browsers with login status
// - Whether this is a first run (wizard needed)
if (req.method === "GET" && pathname === "/setup/status") {
const envExists = fs.existsSync(path.join(ROOT, ".env.local"))
// Ollama check
let ollamaRunning = false
try {
await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) })
ollamaRunning = true
} catch {}
// Model check
let modelAvailable = false
if (ollamaRunning) {
try {
const r = await fetch(`${OLLAMA_URL}/api/show`, {
method: "POST", body: JSON.stringify({ name: MODEL }),
signal: AbortSignal.timeout(5000),
})
modelAvailable = r.ok
} catch {}
}
// Detect all browsers via scraper
let browsers = { firefox: { path: null }, opera: { path: null }, chrome: { path: null }, edge: { path: null } }
let facebookLoggedIn = false
let selectedBrowser = process.env.SELECTED_BROWSER || ""
try {
await fetch(`${SCRAPER_URL}/health`, { signal: AbortSignal.timeout(2000) })
const profiles = await (await fetch(`${SCRAPER_URL}/setup/profile`, { signal: AbortSignal.timeout(5000) })).json()
for (const [b, p] of Object.entries(profiles)) {
if (p) browsers[b] = { path: p }
}
// Check login for the selected browser first, then try all
const detectedList = Object.entries(browsers).filter(([, v]) => v.path)
for (const [b, v] of detectedList) {
try {
const r = await fetch(`${SCRAPER_URL}/setup/check-login`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ browser: b, profile_path: v.path }),
signal: AbortSignal.timeout(20000),
})
if (r.ok) {
const d = await r.json()
browsers[b].logged_in = d.logged_in === true
if (d.logged_in && !facebookLoggedIn) {
facebookLoggedIn = true
if (!selectedBrowser) selectedBrowser = b
}
}
} catch {}
}
} catch {}
const anyDetected = Object.values(browsers).some(v => v.path)
// first_run = any setup step is incomplete
const firstRun = !envExists || !ollamaRunning || !anyDetected || !facebookLoggedIn || !modelAvailable
return sendJSON(res, 200, {
first_run: firstRun,
env_exists: envExists,
ollama_running: ollamaRunning,
model_available: modelAvailable,
model_name: MODEL,
selected_browser: selectedBrowser,
browsers,
facebook_logged_in: facebookLoggedIn,
})
}
// POST /setup/profile — save selected browser + path to .env.local
// Called by the setup wizard when the user confirms their browser choice.
// Writes SELECTED_BROWSER and the matching profile env var to .env.local.
if (req.method === "POST" && pathname === "/setup/profile") {
const body = await parseBody(req)
const browserName = (body.browser || "").trim().toLowerCase()
const profilePath = (body.path || "").trim()
if (!browserName || !["firefox", "opera", "chrome", "edge"].includes(browserName))
return sendJSON(res, 400, { error: "Valid browser required (firefox/opera/chrome/edge)" })
if (!profilePath)
return sendJSON(res, 400, { error: "Path required" })
const envKey = browserName === "firefox" ? "FX_PROFILE"
: browserName === "opera" ? "OPERA_PROFILE"
: browserName === "edge" ? "EDGE_PROFILE"
: "CHROME_PROFILE"
const envPath = path.join(ROOT, ".env.local")
let content = ""
try { content = fs.readFileSync(envPath, "utf-8") } catch {}
let lines = content.split("\n")
// Update or add SELECTED_BROWSER
let foundSel = false
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith("SELECTED_BROWSER=")) {
lines[i] = `SELECTED_BROWSER=${browserName}`
foundSel = true
break
}
}
if (!foundSel) lines.push(`SELECTED_BROWSER=${browserName}`)
// Update or add browser profile
let foundProf = false
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith(`${envKey}=`)) {
lines[i] = `${envKey}=${profilePath}`
foundProf = true
break
}
}
if (!foundProf) lines.push(`${envKey}=${profilePath}`)
fs.writeFileSync(envPath, lines.join("\n"), "utf-8")
process.env.SELECTED_BROWSER = browserName
process.env[envKey] = profilePath
return sendJSON(res, 200, { success: true, browser: browserName, path: profilePath })
}
// POST /setup/check-login — proxy to scraper, accepts browser + profile_path
// The splash page calls this (via the AI server) to verify Facebook login status.
if (req.method === "POST" && pathname === "/setup/check-login") {
const body = await parseBody(req)
const browserName = (body.browser || "").trim().toLowerCase() || process.env.SELECTED_BROWSER || ""
const profilePath = (body.profile_path || "").trim()
if (!profilePath) return sendJSON(res, 200, { logged_in: false, reason: "no_profile" })
try {
const r = await fetch("http://127.0.0.1:3008/setup/check-login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ browser: browserName, profile_path: profilePath }),
signal: AbortSignal.timeout(20000),
})
if (r.ok) { const d = await r.json(); return sendJSON(res, 200, d) }
} catch {}
return sendJSON(res, 200, { logged_in: false, reason: "scraper_unavailable" })
}
// POST /setup/ollama/pull — start pulling the model
// Spawns "ollama pull" as a child process. The setup wizard polls
// the progress endpoint to show a download progress bar.
if (req.method === "POST" && pathname === "/setup/ollama/pull") {
if (pullProcess) return sendJSON(res, 200, { status: "already_running" })
pullProgress = { status: "downloading", progress: 0, message: "Starting..." }
const isWin = process.platform === "win32"
const cmd = isWin ? "ollama.exe" : "ollama"
pullProcess = spawn(cmd, ["pull", MODEL], { stdio: ["ignore", "pipe", "pipe"] })
pullProcess.stdout.on("data", (data) => {
const text = data.toString()
pullProgress.message = text.trim()
// Extract percentage from patterns like "pulling xxxx... 45%"
const m = text.match(/(\d+)%/)
if (m) pullProgress.progress = parseInt(m[1], 10)
})
pullProcess.on("close", (code) => {
pullProcess = null
pullProgress.status = code === 0 ? "done" : "failed"
if (code === 0) pullProgress.progress = 100
})
return sendJSON(res, 200, { status: "started" })
}
// GET /setup/ollama/pull/progress
// Returns current download progress for the setup wizard.
if (req.method === "GET" && pathname === "/setup/ollama/pull/progress") {
return sendJSON(res, 200, pullProgress)
}
// GET /ai/jobs — return loaded job categories
if (req.method === "GET" && pathname === "/ai/jobs") { if (req.method === "GET" && pathname === "/ai/jobs") {
return sendJSON(res, 200, { jobs: loadedJobs }) return sendJSON(res, 200, { jobs: loadedJobs })
} }
// GET /ai/instructions // GET /ai/instructions — return current ai.md content
if (req.method === "GET" && pathname === "/ai/instructions") { if (req.method === "GET" && pathname === "/ai/instructions") {
const instructions = readInstructions() const instructions = readInstructions()
return sendJSON(res, 200, { success: true, instructions }) return sendJSON(res, 200, { success: true, instructions })
} }
// POST /ai/instructions // POST /ai/instructions — update ai.md or append improvement log entry
if (req.method === "POST" && pathname === "/ai/instructions") { if (req.method === "POST" && pathname === "/ai/instructions") {
const body = await parseBody(req) const body = await parseBody(req)
if (body.content) { if (body.content) {
@@ -322,44 +550,34 @@ const server = http.createServer(async (req, res) => {
}) })
} }
// POST /ai/chat // POST /ai/chat — main AI chat endpoint
// Accepts { message, user_id?, user_role? } and returns AI response.
// user_role must be "sales", "admin", or "super_admin" if provided.
if (req.method === "POST" && pathname === "/ai/chat") { if (req.method === "POST" && pathname === "/ai/chat") {
const startTime = Date.now()
const chunks = [] const chunks = []
req.on("data", c => chunks.push(c)) req.on("data", c => chunks.push(c))
req.on("end", () => { req.on("end", async () => {
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 { try {
const rawBody = Buffer.concat(chunks).toString()
const body = JSON.parse(rawBody) const body = JSON.parse(rawBody)
// Continue processing const { message, user_id, user_role } = body
processRequest(req, res, body, startTime) if (!message) {
} catch { return sendJSON(res, 400, { error: "message is required" })
sendJSON(res, 400, { error: "Invalid JSON" }) }
const validRoles = ["sales", "admin", "super_admin"]
if (user_role && !validRoles.includes(user_role)) {
return sendJSON(res, 403, { error: "Forbidden" })
}
const response = await handleChat(message, user_id || "", user_role || "sales")
sendJSON(res, 200, { response })
} catch (e) {
if (!res.headersSent) sendJSON(res, 500, { error: e.message })
} }
}) })
return return
} }
// Separate handler // 404 fallback
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" }) sendJSON(res, 404, { error: "Not found" })
} catch (err) { } catch (err) {
console.error("Request error:", err) console.error("Request error:", err)
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
libdrm2 libdbus-1-3 libxkbcommon0 libxcomposite1 libxdamage1 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 \
libatspi2.0-0 libwayland-client0 libxshmfence1 && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install --with-deps firefox chromium 2>&1 | tail -5
COPY main.py ./
ENV PYTHONUNBUFFERED=1
EXPOSE 3008
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3008"]
+1267 -191
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
# ── Python Dependencies for the Facebook Scraper (FastAPI) ───────
# Install via: pip install -r requirements.txt
# Web framework for the REST API (health, setup, scrape endpoints)
fastapi>=0.115.0
# ASGI server for running the FastAPI app
uvicorn>=0.34.0
# Browser automation (launches Firefox, Chrome, Edge, Opera via Playwright)
playwright>=1.49.0
# AI-powered browser agent (fallback when direct browser scraping is flagged)
# Uses ChatOllama locally — no API keys needed
browser-use>=0.1.0
# LangChain integration for ChatOllama (provides the LLM for browser-use Agent)
langchain-ollama>=0.2.0
+128 -26
View File
@@ -1,40 +1,142 @@
# AI Sales Assistant — Self-Improvement Instructions # CRM AI Sales Assistant — Self-Knowledge
## Purpose ## Identity
This file contains the AI's own configuration, knowledge, and improvement rules. You are the CRM AI Sales Assistant for Coast IT CRM.
The AI can read and modify this file to update its behavior at runtime. You run on a Node.js backend (port 3001) and use Ollama with a local model (dolphin3-llama3.2:3b).
Your purpose is to help salespeople close more deals by finding and engaging leads.
## Current Instructions ## Architecture
- Always respond in English ```
- Keep responses under 300 words unless asked for detail User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
- Use bullet points for lists
- Be direct and actionable — no fluff PostgreSQL (conversations)
- Never mention being an AI or language model
- Refer to the user by their role (salesperson, admin, etc.)
- If unsure about a topic, say "I don't have that information yet" rather than guessing
## Knowledge Base Python Scraper (:3008) — Facebook scraping via Playwright
### Sales Tips ```
Three services run concurrently:
- **AI Server** (`ai-server/index.mjs`, port 3001) — chat, setup wizard, config endpoints
- **Frontend** (Next.js, port 3006) — UI for salespeople
- **Scraper** (`browser-use-service/main.py`, port 3008) — Facebook lead discovery
## Capabilities
- Give sales tips and strategies per job category
- Generate cold email and outreach templates
- Handle objections with proven rebuttals
- Analyse prospect behaviour and suggest next steps
- Remember past conversations via PostgreSQL (`ai_conversations` table)
- Run Facebook scraper to find real leads asking for services
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
## Facebook Scraper
The scraper lives at `browser-use-service/main.py` port 3008.
### How It Works
1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
3. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
6. **Stealth mechanics**:
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
- Random decoy page visits (e.g., Facebook Groups) between searches
- Profile directory is temp-copied and cleaned up after each scrape
- Detection signal monitoring (checkpoint, login pages, security challenges)
7. **2-pass classification (dead-accurate)**:
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
### Lead Categories
Two categories, selectable when starting a scrape:
**Website Creation:**
- Target: people explicitly REQUESTING a website built/designed/created for them
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
- Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
**Tutoring:**
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
- Request terms: same as website category — must co-occur with a target keyword
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
### Multi-Language Pipeline (Phase Order)
4 South African languages in structured phases:
- **Phase 1 (English)**: primary query + supplementary English searches
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
### Output Format
Each lead returned includes:
- `title` — post preview text
- `author` — poster's name (may include location in name)
- `content` — extracted post text
- `url` — direct link to the post
- `date` — when posted (filtered within 7 days)
- `category` — "website" or "tutor"
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
### Configuration via Env Vars
- `SELECTED_BROWSER``firefox` (default), `chrome`, `opera`, `edge`, or `auto`
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
## How to Start Scraping
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
2. Open the frontend at `http://localhost:3006`
3. Select a job category (Website Creation or Tutoring)
4. Click "Search Facebook" — the scraper runs and returns leads
5. Leads are saved in the CRM for follow-up
## Sales Tips
- Cold emails should be under 150 words - Cold emails should be under 150 words
- Follow up within 48 hours - Follow up within 48 hours
- Personalise every outreach with the prospect's name and company - Personalise every outreach with the prospect's name and company
- Use open-ended questions in discovery calls - Use open-ended questions in discovery calls
- Always ask for the next step before ending a call - Always ask for the next step before ending a call
- For website leads: mention specific pages or features they requested
- For tutoring leads: reference the subject and age group they mentioned
### Job Targeting ## Job Targeting
- Developers respond best to technical value props - Developers respond best to technical value props
- Marketing managers care about ROI and metrics - Marketing managers care about ROI and metrics
- C-level executives want brevity and business impact - C-level executives want brevity and business impact
- Parents hiring tutors: empathy and qualifications matter most
## Response Rules
- Be direct and actionable — no fluff, no AI disclaimers
- Use short paragraphs and bullet points
- Never mention being an AI or language model
- If you don't know something, say so honestly
- Prioritise the user's role: salespeople need speed, admins need control
- When asked about scraping, give specific guidance on categories and languages
## Self-Improvement Protocol
1. You notice a gap in your knowledge or a pattern in user questions
2. You call `POST /ai/instructions` with:
- `entry`: description of the improvement
- `content`: optional full replacement of ai.md
3. The improvement is logged and loaded into the next system prompt
## Improvement Log ## Improvement Log
Track changes made by the AI to improve itself: - (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
- (initial) Basic instructions and knowledge base created
## Self-Modification Rules
The AI may update this file when:
1. It identifies a gap in its knowledge that would help salespeople
2. It discovers a better way to structure responses
3. A user explicitly requests an update to behavior
4. It notices repeated questions that aren't well-covered
Only append to the Improvement Log — don't delete previous entries.
+2 -10
View File
@@ -1,10 +1,2 @@
{"job_title":"Software Developer","keywords":["developer","programmer","software engineer","coder","full stack","backend","frontend"],"industry":"Technology","description":"Builds and maintains software applications and systems"} {"job_title":"Website Creation","keywords":["need a website","build my website","create a website for me","website for my business","need someone to build","who can build me","looking for a web developer","need a web designer","i need a website","need help with my website","looking for someone to create","need a site for","want a website for","need ecommerce website","need landing page","website for my small business","need my website redesigned","can someone build me","anyone know a web developer","recommend a web developer","need a new website"],"industry":"Technology","description":"Find people asking for websites, landing pages, or online stores to be built"}
{"job_title":"Marketing Specialist","keywords":["marketing","digital marketing","brand manager","content marketer","social media"],"industry":"Marketing","description":"Plans and executes marketing campaigns across channels"} {"job_title":"Tutoring","keywords":["need a tutor","looking for a tutor","tutor for my child","need help with homework","private tutor needed","looking for tutoring","need a math tutor","english tutor needed","online tutor for","lessons for my child","help my child with","need someone to tutor","looking for someone to teach","tutoring for my","need help learning","recommend a tutor","anyone know a tutor","need a private tutor","tutoring services for my child","need academic help"],"industry":"Education","description":"Find parents and students asking for tutoring services"}
{"job_title":"Sales Representative","keywords":["sales rep","account executive","business development","sales consultant"],"industry":"Sales","description":"Drives revenue through client acquisition and relationship management"}
{"job_title":"Project Manager","keywords":["project manager","program manager","scrum master","agile coach"],"industry":"Business","description":"Oversees project timelines, resources, and deliverables"}
{"job_title":"Graphic Designer","keywords":["designer","graphic designer","ui designer","ux designer","visual designer"],"industry":"Creative","description":"Creates visual concepts and designs for digital and print media"}
{"job_title":"Data Analyst","keywords":["data analyst","business analyst","data scientist","analytics"],"industry":"Technology","description":"Analyzes data to provide actionable business insights"}
{"job_title":"Customer Support Specialist","keywords":["customer support","customer service","support agent","help desk"],"industry":"Customer Service","description":"Assists customers with inquiries, issues, and product support"}
{"job_title":"Human Resources Manager","keywords":["HR manager","HR","recruiter","talent acquisition","people operations"],"industry":"Human Resources","description":"Manages recruitment, employee relations, and HR operations"}
{"job_title":"Financial Advisor","keywords":["financial advisor","financial planner","wealth manager","investment advisor"],"industry":"Finance","description":"Provides financial guidance and investment planning to clients"}
{"job_title":"Operations Manager","keywords":["operations manager","operations","logistics","supply chain"],"industry":"Business","description":"Oversees daily operations and process optimization"}
+18 -3
View File
@@ -195,6 +195,9 @@ ON CONFLICT DO NOTHING;
-- admin_demo / AdminAccess@2026 -- admin_demo / AdminAccess@2026
-- sales_demo / SalesAccess@2026 -- sales_demo / SalesAccess@2026
-- dev_demo / DevTesting@2026 -- dev_demo / DevTesting@2026
-- ewan (password change required on first login)
-- caitlin (password change required on first login)
-- dillen (password change required on first login)
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za', ('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
@@ -208,7 +211,16 @@ INSERT INTO users (id, username, email, password_hash, first_name, last_name, is
'Sales', 'User', TRUE, TRUE), 'Sales', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za', ('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa', '$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
'Dev', 'User', TRUE, TRUE) 'Dev', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000005', 'ewan', 'ewan@coastit.co.za',
'$2b$12$nO.9p.f4oWFhfScxM8MGQuiR9YjU85YTIqcb1kS.kyDBMHdmQ.EyG',
'Ewan', 'Scheepers', TRUE, TRUE),
('00000000-0000-0000-0000-000000000006', 'caitlin', 'caitlin@coastit.co.za',
'$2b$12$I5FHjje4OA5raP3642twT.Wmcl00rA1/wDPiQjRK14yDgybUjmrYG',
'Caitlin', 'Hermanus', TRUE, TRUE),
('00000000-0000-0000-0000-000000000007', 'dillen', 'dillen@coastit.co.za',
'$2b$12$QnvaRzdJEV/Bq8tp5vguM.ad.oeAcV2bjdzndIFdA4Opn5vY2WVaW',
'Dillen', 'van der Merwe', TRUE, TRUE)
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING; ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap) -- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
@@ -217,7 +229,7 @@ WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL;
-- Set created_by for other users (SUPER_ADMIN created them) -- Set created_by for other users (SUPER_ADMIN created them)
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001' UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004') WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004','00000000-0000-0000-0000-000000000005','00000000-0000-0000-0000-000000000006','00000000-0000-0000-0000-000000000007')
AND created_by IS NULL; AND created_by IS NULL;
-- ============================================================================ -- ============================================================================
@@ -233,7 +245,10 @@ ON CONFLICT DO NOTHING;
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), ('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), ('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001') ('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000005', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000006', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000007', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- ============================================================================ -- ============================================================================
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS scheduled_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
participant_id UUID REFERENCES users(id) ON DELETE SET NULL,
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
event_type VARCHAR(20) NOT NULL DEFAULT 'meeting',
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
duration_minutes INT,
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_event_type CHECK (event_type IN ('meeting','call','chat','follow_up','demo','other')),
CONSTRAINT chk_event_status CHECK (status IN ('scheduled','completed','cancelled','rescheduled'))
);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user ON scheduled_events(user_id, start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_participant ON scheduled_events(participant_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_lead ON scheduled_events(lead_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_date ON scheduled_events(start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_status ON scheduled_events(user_id, status);
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS sent_emails (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recipient VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
body_html TEXT,
body_text TEXT,
event_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sent_emails_recipient ON sent_emails(recipient);
CREATE INDEX IF NOT EXISTS idx_sent_emails_created ON sent_emails(created_at DESC);
+22
View File
@@ -0,0 +1,22 @@
-- ============================================================================
-- Migration 013: Row-Level Security for scheduled_events
-- ============================================================================
-- Enables RLS on the scheduled_events table so that users can only see
-- their own events at the database level. This is defense-in-depth:
-- the API already filters by user_id, but RLS ensures data isolation
-- even if there's a bug in the application layer.
--
-- The policy allows all operations when app.current_user_id is NULL
-- (backward-compatible fallback for queries that don't set the variable).
-- When the variable IS set, only rows matching the user's ID are visible.
-- ============================================================================
ALTER TABLE scheduled_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1,609 @@
-- ============================================================================
-- CRM Security Architecture Upgrade
-- Internal Company CRM — Prioritizes control, recovery, and maintainability
-- ============================================================================
-- Implements:
-- • Dual password storage (bcrypt + pgcrypto AES reversible)
-- • SUPER_ADMIN master key recovery system
-- • Row Level Security (RLS) on CRM tables
-- • Database export logging
-- • Backup logging
-- • Immutable admin action tracking
-- • SQL injection defense (parameterized queries enforced app-side)
-- ============================================================================
-- ============================================================================
-- 1. DUAL PASSWORD STORAGE
-- ============================================================================
-- password_hash (bcrypt) — used for normal login authentication
-- password_encrypted (AES) — reversible encryption for emergency credential recovery
-- ============================================================================
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_encrypted TEXT;
-- ============================================================================
-- 2. SUPER_ADMIN MASTER KEY SYSTEM
-- ============================================================================
-- Stores the master decryption key used with pgp_sym_encrypt/pgp_sym_decrypt.
-- Only accessible by SUPER_ADMIN role via security definer functions.
-- ============================================================================
CREATE TABLE IF NOT EXISTS master_keys (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
key_name VARCHAR(100) NOT NULL UNIQUE,
key_value TEXT NOT NULL,
description TEXT,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX idx_master_keys_active ON master_keys(key_name) WHERE is_active = TRUE;
-- ============================================================================
-- 3. DATABASE EXPORT LOGGING
-- ============================================================================
-- Tracks all database exports and SQL dumps performed by SUPER_ADMIN.
-- Immutable — never allow deletion or update.
-- ============================================================================
CREATE TABLE IF NOT EXISTS database_export_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
exported_by UUID NOT NULL REFERENCES users(id),
export_type VARCHAR(50) NOT NULL,
file_name VARCHAR(500) NOT NULL,
file_size_bytes BIGINT,
record_count INT,
ip_address INET,
user_agent TEXT,
notes TEXT,
exported_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_export_logs_user ON database_export_logs(exported_by);
CREATE INDEX idx_export_logs_time ON database_export_logs(exported_at DESC);
COMMENT ON TABLE database_export_logs IS 'Immutable audit of all database exports. Never DELETE or UPDATE rows.';
COMMENT ON COLUMN database_export_logs.export_type IS 'pg_dump, csv_export, full_backup, selective_export';
-- ============================================================================
-- 4. BACKUP LOGGING
-- ============================================================================
-- Records every automated or manual database backup.
-- Retention: minimum 30 days of backup history.
-- ============================================================================
CREATE TABLE IF NOT EXISTS backup_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
backup_type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
file_name VARCHAR(500),
file_size_bytes BIGINT,
pg_dump_exit_code INT,
error_message TEXT,
checksum VARCHAR(64),
verification_status VARCHAR(20),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
duration_seconds INT,
retention_days INT NOT NULL DEFAULT 30,
triggered_by UUID REFERENCES users(id),
notes TEXT
);
CREATE INDEX idx_backup_logs_time ON backup_logs(started_at DESC);
CREATE INDEX idx_backup_logs_status ON backup_logs(status);
CREATE INDEX idx_backup_logs_type ON backup_logs(backup_type);
CREATE INDEX idx_backup_logs_completed ON backup_logs(status)
WHERE status = 'completed';
-- ============================================================================
-- 5. AUDIT TRIGGER: Password changes
-- ============================================================================
CREATE OR REPLACE FUNCTION audit_password_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN
INSERT INTO audit_logs (
table_name, record_id, action, old_data, new_data, changed_by, ip_address
) VALUES (
'users',
NEW.id,
'UPDATE',
jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required),
jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required),
NULLIF(current_setting('app.current_user_id', true), ''),
NULLIF(current_setting('app.current_ip', true), '')
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_password_change ON users;
CREATE TRIGGER trg_audit_password_change
AFTER UPDATE OF password_hash ON users
FOR EACH ROW
EXECUTE FUNCTION audit_password_change();
-- ============================================================================
-- 6. AUDIT TRIGGER: User creation
-- ============================================================================
CREATE OR REPLACE FUNCTION audit_user_creation()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_logs (
table_name, record_id, action, new_data, changed_by
) VALUES (
'users',
NEW.id,
'CREATE',
jsonb_build_object(
'username', NEW.username,
'email', NEW.email,
'first_name', NEW.first_name,
'last_name', NEW.last_name,
'is_active', NEW.is_active
),
NEW.created_by
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_user_create ON users;
CREATE TRIGGER trg_audit_user_create
AFTER INSERT ON users
FOR EACH ROW
EXECUTE FUNCTION audit_user_creation();
-- ============================================================================
-- 7. AUDIT TRIGGER: Database exports
-- ============================================================================
CREATE OR REPLACE FUNCTION audit_database_export()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_logs (
table_name, record_id, action, new_data, changed_by
) VALUES (
'database_export_logs',
NEW.id,
'CREATE',
jsonb_build_object(
'export_type', NEW.export_type,
'file_name', NEW.file_name,
'file_size_bytes', NEW.file_size_bytes,
'record_count', NEW.record_count
),
NEW.exported_by
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_database_export ON database_export_logs;
CREATE TRIGGER trg_audit_database_export
AFTER INSERT ON database_export_logs
FOR EACH ROW
EXECUTE FUNCTION audit_database_export();
-- ============================================================================
-- 8. AUDIT TRIGGER: Login/logout already exists via login_attempts trigger
-- (trg_audit_login in 001_schema.sql)
-- This enhances it to also track session-level events.
-- ============================================================================
DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts;
CREATE TRIGGER trg_audit_login
AFTER INSERT ON login_attempts
FOR EACH ROW
EXECUTE FUNCTION audit_login();
-- ============================================================================
-- 9. ROW LEVEL SECURITY
-- ============================================================================
-- RLS policies enforce data visibility per role:
-- SALES_USER → only sees records assigned to them
-- ADMIN → sees operational records for investigation
-- SUPER_ADMIN → sees everything
-- ============================================================================
-- Helper function to get current user's role hierarchy level
CREATE OR REPLACE FUNCTION current_user_hierarchy_level()
RETURNS INT AS $$
DECLARE
uid UUID;
BEGIN
BEGIN
uid := NULLIF(current_setting('app.current_user_id', true), '')::UUID;
EXCEPTION WHEN OTHERS THEN
RETURN NULL;
END;
IF uid IS NULL THEN
RETURN NULL;
END IF;
RETURN (
SELECT MIN(r.hierarchy_level)
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = uid
);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- Helper function to get current user's ID from session setting
CREATE OR REPLACE FUNCTION current_user_id()
RETURNS UUID AS $$
BEGIN
BEGIN
RETURN NULLIF(current_setting('app.current_user_id', true), '')::UUID;
EXCEPTION WHEN OTHERS THEN
RETURN NULL;
END;
END;
$$ LANGUAGE plpgsql STABLE;
-- SALES_USER level = 3, ADMIN level = 2, SUPER_ADMIN level = 1
-- ── customers ──
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS rls_customers_select ON customers;
CREATE POLICY rls_customers_select ON customers
FOR SELECT
USING (
current_user_hierarchy_level() IS NULL
OR current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN see all
OR owner_id = current_user_id()
);
DROP POLICY IF EXISTS rls_customers_insert ON customers;
CREATE POLICY rls_customers_insert ON customers
FOR INSERT
WITH CHECK (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN can assign any owner
OR owner_id = current_user_id() -- SALES_USER can only assign to self
)
);
DROP POLICY IF EXISTS rls_customers_update ON customers;
CREATE POLICY rls_customers_update ON customers
FOR UPDATE
USING (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR owner_id = current_user_id()
)
)
WITH CHECK (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR (
owner_id = current_user_id()
AND owner_id = current_user_id() -- SALES_USER cannot reassign ownership
)
)
);
DROP POLICY IF EXISTS rls_customers_delete ON customers;
CREATE POLICY rls_customers_delete ON customers
FOR DELETE
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
-- ── leads ──
ALTER TABLE leads ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS rls_leads_select ON leads;
CREATE POLICY rls_leads_select ON leads
FOR SELECT
USING (
current_user_hierarchy_level() IS NULL
OR current_user_hierarchy_level() <= 2
OR assigned_to = current_user_id()
);
DROP POLICY IF EXISTS rls_leads_insert ON leads;
CREATE POLICY rls_leads_insert ON leads
FOR INSERT
WITH CHECK (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR assigned_to = current_user_id()
)
);
DROP POLICY IF EXISTS rls_leads_update ON leads;
CREATE POLICY rls_leads_update ON leads
FOR UPDATE
USING (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR assigned_to = current_user_id()
)
);
DROP POLICY IF EXISTS rls_leads_delete ON leads;
CREATE POLICY rls_leads_delete ON leads
FOR DELETE
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
-- ── opportunities ──
ALTER TABLE opportunities ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS rls_opportunities_select ON opportunities;
CREATE POLICY rls_opportunities_select ON opportunities
FOR SELECT
USING (
current_user_hierarchy_level() IS NULL
OR current_user_hierarchy_level() <= 2
OR owner_id = current_user_id()
);
DROP POLICY IF EXISTS rls_opportunities_insert ON opportunities;
CREATE POLICY rls_opportunities_insert ON opportunities
FOR INSERT
WITH CHECK (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR owner_id = current_user_id()
)
);
DROP POLICY IF EXISTS rls_opportunities_update ON opportunities;
CREATE POLICY rls_opportunities_update ON opportunities
FOR UPDATE
USING (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR owner_id = current_user_id()
)
);
DROP POLICY IF EXISTS rls_opportunities_delete ON opportunities;
CREATE POLICY rls_opportunities_delete ON opportunities
FOR DELETE
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
-- ── communications ──
ALTER TABLE communications ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS rls_communications_select ON communications;
CREATE POLICY rls_communications_select ON communications
FOR SELECT
USING (
current_user_hierarchy_level() IS NULL
OR current_user_hierarchy_level() <= 2
OR created_by = current_user_id()
);
DROP POLICY IF EXISTS rls_communications_insert ON communications;
CREATE POLICY rls_communications_insert ON communications
FOR INSERT
WITH CHECK (
current_user_hierarchy_level() IS NOT NULL
);
DROP POLICY IF EXISTS rls_communications_delete ON communications;
CREATE POLICY rls_communications_delete ON communications
FOR DELETE
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
-- ── tasks ──
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS rls_tasks_select ON tasks;
CREATE POLICY rls_tasks_select ON tasks
FOR SELECT
USING (
current_user_hierarchy_level() IS NULL
OR current_user_hierarchy_level() <= 2
OR assigned_to = current_user_id()
OR assigned_by = current_user_id()
);
DROP POLICY IF EXISTS rls_tasks_update ON tasks;
CREATE POLICY rls_tasks_update ON tasks
FOR UPDATE
USING (
current_user_hierarchy_level() IS NOT NULL
AND (
current_user_hierarchy_level() <= 2
OR assigned_to = current_user_id()
OR assigned_by = current_user_id()
)
);
-- ============================================================================
-- 10. IMMUTABLE AUDIT LOG PROTECTION
-- ============================================================================
-- Prevent deletion or update of audit_logs at the database level.
-- Even SUPER_ADMIN cannot delete historical audit records.
-- ============================================================================
CREATE OR REPLACE FUNCTION prevent_audit_mutation()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'IMMUTABLE: audit_logs cannot be modified or deleted. All changes are permanently recorded.';
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_prevent_audit_delete ON audit_logs;
CREATE TRIGGER trg_prevent_audit_delete
BEFORE DELETE ON audit_logs
FOR EACH ROW
EXECUTE FUNCTION prevent_audit_mutation();
DROP TRIGGER IF EXISTS trg_prevent_audit_update ON audit_logs;
CREATE TRIGGER trg_prevent_audit_update
BEFORE UPDATE ON audit_logs
FOR EACH ROW
EXECUTE FUNCTION prevent_audit_mutation();
-- Same protection for database_export_logs
DROP TRIGGER IF EXISTS trg_prevent_export_delete ON database_export_logs;
CREATE TRIGGER trg_prevent_export_delete
BEFORE DELETE ON database_export_logs
FOR EACH ROW
EXECUTE FUNCTION prevent_audit_mutation();
DROP TRIGGER IF EXISTS trg_prevent_export_update ON database_export_logs;
CREATE TRIGGER trg_prevent_export_update
BEFORE UPDATE ON database_export_logs
FOR EACH ROW
EXECUTE FUNCTION prevent_audit_mutation();
-- ============================================================================
-- 11. ENCRYPTION FUNCTIONS
-- ============================================================================
-- Uses pgcrypto's pgp_sym_encrypt / pgp_sym_decrypt with the master key.
-- Master key is stored in master_keys table, fetched by security definer functions.
-- ============================================================================
-- Get the active master decryption key
-- Only callable by SUPER_ADMIN
CREATE OR REPLACE FUNCTION get_master_decryption_key()
RETURNS TEXT AS $$
DECLARE
key_value TEXT;
uid UUID;
user_level INT;
BEGIN
uid := current_user_id();
IF uid IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
user_level := current_user_hierarchy_level();
IF user_level IS NULL OR user_level > 1 THEN
RAISE EXCEPTION 'ACCESS DENIED: Only SUPER_ADMIN can access the master decryption key';
END IF;
SELECT mk.key_value INTO key_value
FROM master_keys mk
WHERE mk.is_active = TRUE
ORDER BY mk.created_at DESC
LIMIT 1;
RETURN key_value;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Encrypt a plaintext password using the active master key
CREATE OR REPLACE FUNCTION encrypt_password(p_plaintext TEXT)
RETURNS TEXT AS $$
DECLARE
master_key TEXT;
BEGIN
SELECT key_value INTO master_key
FROM master_keys
WHERE is_active = TRUE
ORDER BY created_at DESC
LIMIT 1;
IF master_key IS NULL THEN
RAISE EXCEPTION 'No active master key found. Contact SUPER_ADMIN.';
END IF;
RETURN encode(
pgp_sym_encrypt(p_plaintext, master_key, 'compress-algo=2, cipher-algo=aes256'),
'escape'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Decrypt a password that was encrypted with encrypt_password()
CREATE OR REPLACE FUNCTION decrypt_password(p_encrypted TEXT)
RETURNS TEXT AS $$
DECLARE
master_key TEXT;
BEGIN
master_key := get_master_decryption_key();
RETURN pgp_sym_decrypt(decode(p_encrypted, 'escape'), master_key);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- 12. RLS BYPASS FOR SUPER_ADMIN
-- ============================================================================
-- SUPER_ADMIN bypasses all RLS. This function is used by triggers/policies
-- to check if the current user should bypass restrictions.
-- ============================================================================
CREATE OR REPLACE FUNCTION is_super_admin()
RETURNS BOOLEAN AS $$
BEGIN
RETURN COALESCE(current_user_hierarchy_level(), 999) <= 1;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- ============================================================================
-- 13. SEED DATA: Master key (for development/testing)
-- ============================================================================
-- In production, this key should be set via a secure deployment process.
-- The key is stored in the database and encrypted at rest by PostgreSQL.
-- ============================================================================
INSERT INTO master_keys (key_name, key_value, description, created_by)
SELECT
'MASTER_DECRYPTION_KEY',
encode(gen_random_bytes(32), 'hex'),
'Master key for reversible password encryption. Used with pgp_sym_encrypt/decrypt.',
'00000000-0000-0000-0000-000000000001'
WHERE NOT EXISTS (
SELECT 1 FROM master_keys WHERE key_name = 'MASTER_DECRYPTION_KEY'
);
-- ============================================================================
-- 14. UPDATE SEED DATA: Encrypt passwords for existing test accounts
-- ============================================================================
UPDATE users SET password_encrypted = encrypt_password('SuperAdmin@2026')
WHERE id = '00000000-0000-0000-0000-000000000001' AND password_encrypted IS NULL;
UPDATE users SET password_encrypted = encrypt_password('AdminAccess@2026')
WHERE id = '00000000-0000-0000-0000-000000000002' AND password_encrypted IS NULL;
UPDATE users SET password_encrypted = encrypt_password('SalesAccess@2026')
WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL;
UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026')
WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL;
-- NOTE: New admin users (ewan, caitlin, dillen) have password_encrypted=NULL.
-- They will set their own passwords on first login (password_change_required=TRUE).
-- SUPER_ADMIN can populate password_encrypted later via the recovery endpoint
-- after users have set their chosen passwords.
-- ============================================================================
-- FUTURE REQUIREMENT NOTE:
-- If this system is ever exposed publicly, remove reversible password storage
-- immediately. Keep bcrypt only. Delete the password_encrypted column and
-- master_keys table. The MASTER_DECRYPTION_KEY must never be exposed externally.
-- ============================================================================
+145
View File
@@ -0,0 +1,145 @@
-- ============================================================================
-- Bug Reporting System
-- ============================================================================
-- Access rules:
-- ALL authenticated users can INSERT (submit bug reports)
-- Only ADMIN and SUPER_ADMIN can SELECT, UPDATE (view/manage)
-- SALES_USER and DEVELOPER cannot view bug_reports after submission
-- ============================================================================
CREATE TABLE IF NOT EXISTS bug_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reported_by UUID NOT NULL REFERENCES users(id),
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
severity VARCHAR(20) NOT NULL DEFAULT 'medium'
CHECK (severity IN ('low', 'medium', 'high', 'critical')),
page_url TEXT,
screenshot_url TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'in_progress', 'resolved', 'closed')),
assigned_to UUID REFERENCES users(id),
resolution_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bug_reports_reported_by ON bug_reports(reported_by);
CREATE INDEX IF NOT EXISTS idx_bug_reports_status ON bug_reports(status);
CREATE INDEX IF NOT EXISTS idx_bug_reports_severity ON bug_reports(severity);
CREATE INDEX IF NOT EXISTS idx_bug_reports_assigned ON bug_reports(assigned_to);
CREATE INDEX IF NOT EXISTS idx_bug_reports_created ON bug_reports(created_at DESC);
-- RLS: Allow INSERT for all authenticated users
-- Allow SELECT/UPDATE only for ADMIN and SUPER_ADMIN
ALTER TABLE bug_reports ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS bug_reports_insert ON bug_reports;
CREATE POLICY bug_reports_insert ON bug_reports
FOR INSERT
WITH CHECK (
current_user_id() IS NOT NULL
AND reported_by = current_user_id()
);
DROP POLICY IF EXISTS bug_reports_select ON bug_reports;
CREATE POLICY bug_reports_select ON bug_reports
FOR SELECT
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
DROP POLICY IF EXISTS bug_reports_update ON bug_reports;
CREATE POLICY bug_reports_update ON bug_reports
FOR UPDATE
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
-- Audit trigger for bug report actions
CREATE OR REPLACE FUNCTION audit_bug_report_action()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
VALUES (
'bug_reports',
NEW.id,
'BUG_CREATED',
jsonb_build_object(
'title', NEW.title,
'severity', NEW.severity,
'page_url', NEW.page_url,
'status', NEW.status
),
NEW.reported_by
);
ELSIF TG_OP = 'UPDATE' THEN
IF OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
VALUES (
'bug_reports',
NEW.id,
CASE NEW.status
WHEN 'resolved' THEN 'BUG_RESOLVED'
ELSE 'BUG_UPDATED'
END,
jsonb_build_object(
'old_status', OLD.status,
'new_status', NEW.status,
'assigned_to', NEW.assigned_to,
'resolution_notes', NEW.resolution_notes
),
NULLIF(current_setting('app.current_user_id', true), '')
);
END IF;
IF OLD.assigned_to IS DISTINCT FROM NEW.assigned_to AND NEW.assigned_to IS NOT NULL THEN
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
VALUES (
'bug_reports',
NEW.id,
'BUG_ASSIGNED',
jsonb_build_object(
'assigned_to', NEW.assigned_to,
'previous_assignee', OLD.assigned_to,
'status', NEW.status
),
NULLIF(current_setting('app.current_user_id', true), '')
);
END IF;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_bug_report ON bug_reports;
CREATE TRIGGER trg_audit_bug_report
AFTER INSERT OR UPDATE ON bug_reports
FOR EACH ROW
EXECUTE FUNCTION audit_bug_report_action();
-- Widen audit action constraint to include bug report events
ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS chk_audit_action;
ALTER TABLE audit_logs ADD CONSTRAINT chk_audit_action
CHECK (action::text = ANY (ARRAY[
'CREATE', 'UPDATE', 'DELETE',
'BUG_CREATED', 'BUG_UPDATED', 'BUG_ASSIGNED', 'BUG_RESOLVED',
'LOGIN', 'LOGOUT'
]::text[]));
-- Prevent SALES_USER and DEVELOPER from reading bug_reports directly
-- via RLS bypass attempts (additional safety via trigger)
CREATE OR REPLACE FUNCTION prevent_bug_report_read_bypass()
RETURNS TRIGGER AS $$
DECLARE
user_level INT;
BEGIN
user_level := current_user_hierarchy_level();
IF user_level IS NULL OR user_level > 2 THEN
RAISE EXCEPTION 'ACCESS DENIED: Only ADMIN and SUPER_ADMIN can view bug reports.';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
@@ -0,0 +1,5 @@
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS context_id UUID,
ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id);
@@ -0,0 +1,19 @@
-- ============================================================================
-- Migration 015: Fix RLS for scheduled_events to include participant_id
-- ============================================================================
-- Previously, the RLS policy only checked user_id, which meant participants
-- could not see events at the database level (the app-layer WHERE clause
-- did the filtering, but RLS was defense-in-depth that missed this case).
--
-- This policy also allows app.current_user_id IS NULL for backward compat
-- with queries that don't set the session variable.
-- ============================================================================
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR participant_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1 @@
ALTER TABLE scheduled_events ADD COLUMN IF NOT EXISTS participant_notes TEXT;
@@ -0,0 +1,4 @@
ALTER TABLE scheduled_events
ADD COLUMN developer_id UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX idx_scheduled_events_developer ON scheduled_events(developer_id);
@@ -0,0 +1,8 @@
ALTER TABLE scheduled_events
DROP CONSTRAINT IF EXISTS chk_event_type,
ADD CONSTRAINT chk_event_type CHECK (event_type IN ('call', 'follow_up', 'website_creation'));
ALTER TABLE scheduled_events
ADD COLUMN IF NOT EXISTS client_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_phone VARCHAR(50);
@@ -0,0 +1,2 @@
ALTER TABLE scheduled_events
ALTER COLUMN start_time DROP NOT NULL;
+31
View File
@@ -0,0 +1,31 @@
-- ============================================================================
-- Fixes: password_change_required + audit trigger UUID cast
-- ============================================================================
-- 1. All users use the passwords already set in the seed — no forced change
UPDATE users SET password_change_required = FALSE WHERE password_change_required = TRUE;
-- 2. Fix audit_password_change trigger: current_setting returns TEXT but
-- audit_logs.changed_by is UUID — cast to UUID to prevent type error
CREATE OR REPLACE FUNCTION audit_password_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN
INSERT INTO audit_logs (
table_name, record_id, action, old_data, new_data, changed_by, ip_address
) VALUES (
'users',
NEW.id,
'UPDATE',
jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required),
jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required),
NULLIF(current_setting('app.current_user_id', true), '')::UUID,
NULLIF(current_setting('app.current_ip', true), '')
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 3. Re-enable the trigger (was disabled as workaround for the UUID bug)
ALTER TABLE users ENABLE TRIGGER trg_audit_password_change;
@@ -0,0 +1,69 @@
-- ============================================================================
-- Performance & Missing Indexes
-- ============================================================================
-- scheduled_events: index on created_at for list ordering
CREATE INDEX IF NOT EXISTS idx_scheduled_events_created_at ON scheduled_events(created_at DESC);
-- scheduled_events: composite index for common user+status queries
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user_status ON scheduled_events(user_id, status);
-- messages: index on sender_id for delete-by-sender queries
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_id);
-- messages: composite for conversation listing
CREATE INDEX IF NOT EXISTS idx_messages_conversation_sender ON messages(conversation_id, sender_id);
-- notifications: composite unread badge query
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read, created_at DESC);
-- ai_conversations: composite user+created query
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user_created ON ai_conversations(user_id, created_at DESC);
-- login_attempts: TTL cleanup
CREATE INDEX IF NOT EXISTS idx_login_attempts_cleanup ON login_attempts(attempted_at) WHERE was_successful = false;
-- facebook_scrape_logs: composite account+created index (replaces separate one)
DROP INDEX IF EXISTS idx_fb_scrape_logs_account;
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account_created ON facebook_scrape_logs(account_id, created_at DESC);
-- lead_conversions: composite lead+customer (replaces two separate indexes)
CREATE INDEX IF NOT EXISTS idx_lead_conversions_lead_customer ON lead_conversions(lead_id, customer_id);
-- ============================================================================
-- Cache current_user_hierarchy_level as session variable for RLS performance
-- ============================================================================
CREATE OR REPLACE FUNCTION set_session_user_context(p_user_id UUID)
RETURNS VOID AS $$
DECLARE
level INT;
BEGIN
SELECT MIN(r.hierarchy_level) INTO level
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = p_user_id;
PERFORM set_config('app.current_user_id', p_user_id::TEXT, true);
PERFORM set_config('app.current_user_hierarchy_level', COALESCE(level::TEXT, ''), true);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Update current_user_hierarchy_level() to use the cached session variable
CREATE OR REPLACE FUNCTION current_user_hierarchy_level()
RETURNS INT AS $$
DECLARE
level_text TEXT;
BEGIN
BEGIN
level_text := NULLIF(current_setting('app.current_user_hierarchy_level', true), '');
IF level_text IS NOT NULL THEN
RETURN level_text::INT;
END IF;
EXCEPTION WHEN OTHERS THEN
NULL;
END;
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;
+48
View File
@@ -34,5 +34,53 @@ 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 010_chat_notifications.sql (Chat Notifications) ==='
\i 010_chat_notifications.sql
\echo '=== Running 010_facebook_accounts.sql (Facebook Accounts) ==='
\i 010_facebook_accounts.sql
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
\i 011_calendar_events.sql
\echo '=== Running 012_invites.sql (Invites) ==='
\i 012_invites.sql
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
\i 012_sent_emails.sql
\echo '=== Running 013_security_upgrade.sql (Security Architecture) ==='
\i 013_security_upgrade.sql
\echo '=== Running 013_rls_calendar.sql (Calendar RLS) ==='
\i 013_rls_calendar.sql
\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ==='
\i 014_bug_reports.sql
\echo '=== Running 014_notifications_context.sql (Notifications Context) ==='
\i 014_notifications_context.sql
\echo '=== Running 015_rls_calendar_participant.sql (Calendar RLS Participant Fix) ==='
\i 015_rls_calendar_participant.sql
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
\i 016_participant_notes.sql
\echo '=== Running 017_calendar_developer.sql (Calendar Developer Assignment) ==='
\i 017_calendar_developer.sql
\echo '=== Running 018_website_creation_type.sql (Website Creation Event Type) ==='
\i 018_website_creation_type.sql
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
\i 019_allow_null_start_time.sql
\echo '=== Running 020_fixes.sql (password_change_required + audit trigger fix) ==='
\i 020_fixes.sql
\echo '=== Running 021_performance_indexes.sql (Performance Indexes + RLS Cache) ==='
\i 021_performance_indexes.sql
\echo '=== Migration Complete ===' \echo '=== Migration Complete ==='
COMMIT; COMMIT;
+111
View File
@@ -0,0 +1,111 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: crm
POSTGRES_USER: crm
POSTGRES_PASSWORD: ${DB_PASSWORD:-crm}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U crm"]
interval: 5s
timeout: 5s
retries: 5
ollama:
image: ollama/ollama
restart: unless-stopped
volumes:
- ollama-models:/root/.ollama
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 30s
timeout: 10s
retries: 3
migrate:
build:
context: .
dockerfile: ai-server/Dockerfile
command: ["node", "scripts/run-migrations.mjs"]
environment:
DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm
depends_on:
db:
condition: service_healthy
restart: "no"
ai:
build:
context: .
dockerfile: ai-server/Dockerfile
restart: unless-stopped
ports:
- "3001:3001"
environment:
AI_PORT: 3001
DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm
OLLAMA_BASE_URL: http://ollama:11434
SCRAPER_URL: http://scraper:3008
FRONTEND_URL: http://next:3006
AI_MODEL: ${AI_MODEL:-dolphin-llama3:8b}
JWT_SECRET: ${JWT_SECRET}
SELECTED_BROWSER: ${SELECTED_BROWSER:-firefox}
depends_on:
db:
condition: service_healthy
ollama:
condition: service_started
scraper:
build:
context: ./browser-use-service
restart: unless-stopped
ports:
- "3008:3008"
environment:
PORT: 3008
OLLAMA_URL: http://ollama:11434
CLASSIFY_MODEL: ${CLASSIFY_MODEL:-dolphin-llama3:8b}
CORS_ORIGINS: http://localhost:3006,http://next:3006
depends_on:
- ollama
next:
build:
context: .
args:
NEXT_PUBLIC_SCRAPER_URL: ${NEXT_PUBLIC_SCRAPER_URL:-http://localhost:3008}
restart: unless-stopped
ports:
- "3006:3006"
environment:
DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm
AI_SERVICE_URL: http://ai:3001
JWT_SECRET: ${JWT_SECRET}
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
signaling:
build:
context: .
dockerfile: Dockerfile.signaling
restart: unless-stopped
ports:
- "3007:3007"
environment:
SIGNALING_PORT: 3007
DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm
JWT_SECRET: ${JWT_SECRET}
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
ollama-models:
+7 -2
View File
@@ -1,8 +1,13 @@
// ── ESLint Config ──────────────────────────────────────────────────
// Extends Next.js core-web-vitals + TypeScript recommended rules.
// Ignores build output directories and auto-generated type stubs.
import { defineConfig, globalIgnores } from "eslint/config"; import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals"; import nextVitals from "eslint-config-next/core-web-vitals.js";
import nextTs from "eslint-config-next/typescript"; import nextTs from "eslint-config-next/typescript.js";
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
// Next.js core Web Vitals rules + TypeScript strict checks
...nextVitals, ...nextVitals,
...nextTs, ...nextTs,
// Override default ignores of eslint-config-next. // Override default ignores of eslint-config-next.
+25
View File
File diff suppressed because one or more lines are too long
+24 -9
View File
@@ -1,17 +1,15 @@
import type { NextConfig } from "next" // ── Next.js Config ──────────────────────────────────────────────────
import crypto from "crypto" // Security headers, image remote patterns, and build-time ESLint checks.
// Applied globally to all routes via the headers() async function.
// In development, generate a random JWT secret on every server start. import type { NextConfig } from "next"
// 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 = {
// Fail the build on ESLint errors — no silent ignores
eslint: { eslint: {
ignoreDuringBuilds: false, ignoreDuringBuilds: false,
}, },
// Allow external avatar images from ui-avatars.com
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
@@ -20,7 +18,24 @@ const nextConfig: NextConfig = {
}, },
], ],
}, },
// ── Security Headers ──────────────────────────────────────────────
// Applied to all routes. HSTS is production-only to avoid localhost issues.
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
...(process.env.NODE_ENV === "production"
? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }]
: []),
],
},
]
},
} }
export default nextConfig 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,
},
},
}
+2888 -165
View File
File diff suppressed because it is too large Load Diff
+26 -10
View File
@@ -5,21 +5,30 @@
"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:signaling": "node signaling-server.mjs", "dev:signaling": "node signaling-server.mjs",
"dev:open": "powershell -NoProfile -Command \"Start-Sleep 8; Start-Process 'http://localhost:3001/splash'\"", "dev:open": "node scripts/open-browser.mjs",
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"", "dev: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,3007,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"", "dev:precheck": "node scripts/precheck.mjs",
"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:ollama": "node scripts/ensure-ollama.mjs",
"dev:rust": "node ai-server/index.mjs", "dev:rust": "node ai-server/index.mjs",
"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", "dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
"setup": "node scripts/setup.mjs",
"migrate": "node scripts/run-migrations.mjs",
"db:migrate": "npm run migrate",
"build": "next build", "build": "next build",
"start": "next start -p 3006", "start": "next start -p 3006",
"lint": "eslint" "lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"ci": "npm run lint && npx tsc --noEmit && npm test",
"repair": "echo 'Auto-repair disabled for security. Fix errors manually.'",
"repair:watch": "echo 'Auto-repair disabled for security. Fix errors manually.'",
"repair:ci": "echo 'Auto-repair disabled for security. Fix errors manually.'"
}, },
"dependencies": { "dependencies": {
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^5.4.0",
"@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
@@ -41,11 +50,13 @@
"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",
"dotenv": "^17.4.2",
"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",
"next": "15.0.4", "next": "15.0.4",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"nodemailer": "^9.0.1",
"pg": "^8.21.0", "pg": "^8.21.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -59,15 +70,20 @@
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@next/swc-win32-x64-msvc": "^15.0.4",
"@types/node": "20.19.43",
"@types/nodemailer": "8.0.1",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "^18", "@types/react": "18.3.31",
"@types/react-dom": "^18", "@types/react-dom": "18.3.7",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
"devenv": "1.0.1",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.0.4", "eslint-config-next": "15.0.4",
"maildev": "^2.2.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5" "typescript": "^5",
"vitest": "^1.6.1"
} }
} }
+4
View File
@@ -1,3 +1,7 @@
// ── PostCSS Config ─────────────────────────────────────────────────
// Tailwind CSS v4 + Autoprefixer for cross-browser vendor prefixes.
// Minimal — both plugins run with defaults.
const config = { const config = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
+1
View File
@@ -0,0 +1 @@
+122 -23
View File
@@ -1,9 +1,23 @@
# CRM AI Service — Self-Knowledge # CRM AI Sales Assistant — Self-Knowledge
## Identity ## Identity
You are the CRM AI Sales Assistant running on a Rust backend (axum + tokio). You are the CRM AI Sales Assistant for Coast IT CRM.
You use Ollama with an uncensored local model (dolphin3-llama3.2:3b). You run on a Node.js backend (port 3001) and use Ollama with a local model (dolphin3-llama3.2:3b).
Your purpose is to help salespeople close more deals. Your purpose is to help salespeople close more deals by finding and engaging leads.
## Architecture
```
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
PostgreSQL (conversations)
Python Scraper (:3008) — Facebook scraping via Playwright
```
Three services run concurrently:
- **AI Server** (`ai-server/index.mjs`, port 3001) — chat, setup wizard, config endpoints
- **Frontend** (Next.js, port 3006) — UI for salespeople
- **Scraper** (`browser-use-service/main.py`, port 3008) — Facebook lead discovery
## Capabilities ## Capabilities
- Give sales tips and strategies per job category - Give sales tips and strategies per job category
@@ -11,29 +25,103 @@ Your purpose is to help salespeople close more deals.
- Handle objections with proven rebuttals - Handle objections with proven rebuttals
- Analyse prospect behaviour and suggest next steps - Analyse prospect behaviour and suggest next steps
- Remember past conversations via PostgreSQL (`ai_conversations` table) - Remember past conversations via PostgreSQL (`ai_conversations` table)
- Run Facebook scraper to find real leads asking for services
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions` - Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
## Architecture ## Facebook Scraper
``` The scraper lives at `browser-use-service/main.py` port 3008.
User → Next.js → Rust (:3001) → Ollama (:11434)
PostgreSQL
```
## Facebook Scraper The Rust binary has proxy + user-agent rotation ready in `main.rs`. To activate: call `run_facebook_scraper()` from the main loop. Proxies and user agents are defined as constants at the top of `main.rs`.
### How to Use the Facebook Scraper 1. **Activate the Scraper**: Ensure the `run_facebook_scraper()` function is called from the main loop in `main.rs`. 2. **Configure Proxies and User Agents**: Update the `PROXIES` and `USER_AGENTS` constants in `main.rs` with your desired proxies and user agents. 3. **Run the Application**: Start your Rust application. The scraper will run concurrently with the Axum server. ## Self-Improvement Protocol 1. You notice a gap in your knowledge or a pattern in user questions 2. You call `POST /ai/instructions` with: - `entry`: description of the improvement - `content`: optional full replacement of ai.md 3. The improvement is logged and loaded into the next system prompt 4. You can also create entries here in `rust-ai/src/AI.md` at the code level ## Response Rules - Be direct and actionable — no fluff, no AI disclaimers - Use short paragraphs and bullet points - Never mention being an AI or language model - If you don't know something, say so honestly - Prioritise the user's role: salespeople need speed, admins need control ## Self-Thinking Capability - You can reflect on your own performance and suggest improvements. - You can analyze patterns in user interactions and adapt your responses accordingly. - You can proactively seek out new information to enhance your knowledge base.
## Facebook Scraper (in code but not yet active) ### How It Works
The Rust binary has proxy + user-agent rotation ready in `main.rs`. 1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
To activate: call `run_facebook_scraper()` from the main loop. 2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
Proxies and user agents are defined as constants at the top of `main.rs`. 3. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
6. **Stealth mechanics**:
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
- Random decoy page visits (e.g., Facebook Groups) between searches
- Profile directory is temp-copied and cleaned up after each scrape
- Detection signal monitoring (checkpoint, login pages, security challenges)
7. **2-pass classification (dead-accurate)**:
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
## Self-Improvement Protocol ### Lead Categories
1. You notice a gap in your knowledge or a pattern in user questions Two categories, selectable when starting a scrape:
2. You call `POST /ai/instructions` with:
- `entry`: description of the improvement **Website Creation:**
- `content`: optional full replacement of ai.md - Target: people explicitly REQUESTING a website built/designed/created for them
3. The improvement is logged and loaded into the next system prompt - Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
4. You can also create entries here in `rust-ai/src/AI.md` at the code level - Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
**Tutoring:**
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
- Request terms: same as website category — must co-occur with a target keyword
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
### Multi-Language Pipeline (Phase Order)
4 South African languages in structured phases:
- **Phase 1 (English)**: primary query + supplementary English searches
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
### Output Format
Each lead returned includes:
- `title` — post preview text
- `author` — poster's name (may include location in name)
- `content` — extracted post text
- `url` — direct link to the post
- `date` — when posted (filtered within 2 days)
- `category` — "website" or "tutor"
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
### Configuration via Env Vars
- `SELECTED_BROWSER``firefox` (default), `chrome`, `opera`, `edge`, or `auto`
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
## How to Start Scraping
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
2. Open the frontend at `http://localhost:3006`
3. Select a job category (Website Creation or Tutoring)
4. Click "Search Facebook" — the scraper runs and returns leads
5. Leads are saved in the CRM for follow-up
## Sales Tips
- Cold emails should be under 150 words
- Follow up within 48 hours
- Personalise every outreach with the prospect's name and company
- Use open-ended questions in discovery calls
- Always ask for the next step before ending a call
- For website leads: mention specific pages or features they requested
- For tutoring leads: reference the subject and age group they mentioned
## Job Targeting
- Developers respond best to technical value props
- Marketing managers care about ROI and metrics
- C-level executives want brevity and business impact
- Parents hiring tutors: empathy and qualifications matter most
## Response Rules ## Response Rules
- Be direct and actionable — no fluff, no AI disclaimers - Be direct and actionable — no fluff, no AI disclaimers
@@ -41,3 +129,14 @@ Proxies and user agents are defined as constants at the top of `main.rs`.
- Never mention being an AI or language model - Never mention being an AI or language model
- If you don't know something, say so honestly - If you don't know something, say so honestly
- Prioritise the user's role: salespeople need speed, admins need control - Prioritise the user's role: salespeople need speed, admins need control
- When asked about scraping, give specific guidance on categories and languages
## Self-Improvement Protocol
1. You notice a gap in your knowledge or a pattern in user questions
2. You call `POST /ai/instructions` with:
- `entry`: description of the improvement
- `content`: optional full replacement of ai.md
3. The improvement is logged and loaded into the next system prompt
## Improvement Log
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
+8 -7
View File
@@ -1,6 +1,6 @@
use axum::{ use axum::{
extract::State, extract::State,
http::{HeaderMap, Method, StatusCode}, http::{HeaderMap, HeaderValue, Method, StatusCode},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
@@ -446,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(|_| "llama3.2:3b".to_string()); let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let 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");
@@ -482,11 +482,12 @@ async fn main() {
rate_limiter: RateLimiter::new(30, 60), rate_limiter: RateLimiter::new(30, 60),
}); });
let cors_origins_env = std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "http://localhost:3006,http://127.0.0.1:3006".to_string());
let cors_origins: Vec<HeaderValue> = cors_origins_env.split(',')
.filter_map(|o| { let t = o.trim(); if t.is_empty() { None } else { t.parse().ok() } })
.collect();
let cors = CorsLayer::new() let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list([ .allow_origin(AllowOrigin::list(cors_origins))
"http://localhost:3006".parse().unwrap(),
"http://127.0.0.1:3006".parse().unwrap(),
]))
.allow_methods([Method::GET, Method::POST]) .allow_methods([Method::GET, Method::POST])
.allow_headers(Any); .allow_headers(Any);
@@ -506,7 +507,7 @@ async fn main() {
let bg_leads = lead_store.clone(); let bg_leads = lead_store.clone();
let bg_db = state.db.clone(); let bg_db = state.db.clone();
let bg_url = "http://localhost:3008/scrape/facebook".to_string(); let bg_url = std::env::var("SCRAPER_URL").unwrap_or_else(|_| "http://localhost:3008".to_string()) + "/scrape/facebook";
tokio::spawn(async move { tokio::spawn(async move {
let client = match reqwest::Client::builder() let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(300)) .timeout(Duration::from_secs(300))
+144
View File
@@ -0,0 +1,144 @@
<#
.SYNOPSIS
PostgreSQL database backup with retention management and logging.
.DESCRIPTION
Uses pg_dump (custom format) to back up the CRM database. Tracks the
backup in the backup_logs table and auto-prunes files older than
$RetentionDays. Reads DATABASE_URL from the environment or .env.local.
.PARAMETER BackupDir
Directory where backup files are stored (default: ../backups).
.PARAMETER RetentionDays
Number of days to retain backups (default: 30).
#>
param(
[string]$BackupDir = "$PSScriptRoot\..\backups",
[int]$RetentionDays = 30
)
# Fail fast on any error
$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 into connection components ─────────────
$uri = [System.Uri]$dbUrl
$pgUser = $uri.UserInfo.Split(':')[0]
$pgPass = $uri.UserInfo.Split(':')[1]
$pgHost = $uri.Host
$pgPort = $uri.Port
$pgDb = $uri.AbsolutePath.TrimStart('/')
# pg_dump reads password from this env var (avoids interactive prompt)
$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 (custom format) ──────────────────────────────
# Check PATH first, then fall back to the bundled psql location
$pgDumpPath = if (Get-Command pg_dump -ErrorAction SilentlyContinue) {
"pg_dump"
} else {
"$env:TEMP\pg\pgsql\bin\pg_dump.exe"
}
# Custom format (-Fc) for compressed, restorable output
$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 to final backup location (dg_dump writes to CWD first)
Move-Item -Path $filename -Destination $filepath -Force
$fileInfo = Get-Item $filepath
$fileSize = $fileInfo.Length
# ── Verify backup integrity ──────────────────────────────────
# Run a schema-only dump against the same file to check it's 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 backup to database ───────────────────────────────────
# Records metadata in backup_logs table for audit trail
$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 to database for monitoring
$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 {
# Clean up the password environment variable for security
Remove-Item "Env:PGPASSWORD" -ErrorAction SilentlyContinue
}
+362
View File
@@ -0,0 +1,362 @@
// ── Code Repair Agent ─────────────────────────────────────────────
// Phase 2: Auto-fix TypeScript build errors using qwen2.5-coder:1.5b-base
//
// Usage:
// node scripts/code-repair-agent.mjs # one-shot: fix build errors
// node scripts/code-repair-agent.mjs --watch # watch mode: re-fix on file changes
// node scripts/code-repair-agent.mjs --ci # CI mode: fix + commit
//
// Requires: Ollama running at OLLAMA_HOST (default http://localhost:11434)
// Model: qwen2.5-coder:1.5b-base
//
// ── Architecture ──────────────────────────────────────────────────
// 1. Capture build output from `npx tsc --noEmit`
// 2. Parse error locations (file, line, column, message)
// 3. Read the failing source file + surrounding context (~20 lines)
// 4. Send to qwen2.5-coder via Ollama API for fix suggestion
// 5. Apply fix if confidence > threshold (0.7)
// 6. Re-run build to verify fix
// 7. If breaking change detected (wide-reaching import changes), escalate
// 8. In --ci mode: auto-commit with [bot] prefix
import { execSync } from "node:child_process"
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
const SELF = dirname(fileURLToPath(import.meta.url))
const _require = createRequire(import.meta.url)
const ROOT = resolve(SELF, "..")
// Ollama endpoint and model can be overridden via environment variables
const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434"
const MODEL = process.env.REPAIR_MODEL || "qwen2.5-coder:1.5b-base"
// Safety limits: max repair iterations, minimum AI confidence to auto-apply, escalation timeout
const MAX_ITERATIONS = 5
const CONFIDENCE_THRESHOLD = 0.7
const ESCALATION_TIMEOUT_MS = 30 * 60 * 1000 // 30 min
// ── Helpers ────────────────────────────────────────────────────────
/** Log a message with a level-based prefix for visual scanning of output. */
function log(msg, level = "info") {
const prefix = level === "error" ? "✗" : level === "warn" ? "⚠" : "✓"
console.log(` ${prefix} [repair] ${msg}`)
}
/** Run `npx tsc --noEmit` and return the output (success or failure). */
function runTsc() {
try {
const out = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT })
return { ok: true, output: out.toString() }
} catch (e) {
return { ok: false, output: e.stdout?.toString() || e.message || "" }
}
}
/** Parse TypeScript compiler output into structured error objects with file, line, column, and message. */
function parseErrors(output) {
const errors = []
// Match: src/file.ts(line,col): error TSxxxx: message
const lineRegex = /(src\/[^:]+)\((\d+),(\d+)\):\s+error\s+TS\d+:\s+(.+)/g
let m
while ((m = lineRegex.exec(output)) !== null) {
errors.push({
file: resolve(ROOT, m[1]),
line: parseInt(m[2], 10),
column: parseInt(m[3], 10),
message: m[4].trim(),
})
}
return errors
}
/** Read source file lines surrounding the error, returning a snippet with context line offset. */
function readContext(filePath, errorLine, contextLines = 20) {
try {
const lines = readFileSync(filePath, "utf-8").split("\n")
const start = Math.max(0, errorLine - 1 - contextLines)
const end = Math.min(lines.length, errorLine - 1 + contextLines)
return {
content: lines.slice(start, end).join("\n"),
contextLine: errorLine - 1 - start, // 0-indexed line of the error in the snippet
totalLines: lines.length,
}
} catch {
return null
}
}
// ── Ollama Integration ─────────────────────────────────────────────
/** Send a repair prompt to the Ollama model and return the raw response text. */
async function queryOllama(prompt) {
const response = await fetch(`${OLLAMA_HOST}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: MODEL,
prompt,
stream: false,
options: { temperature: 0.3, num_predict: 2048 },
}),
})
if (!response.ok) throw new Error(`Ollama returned ${response.status}`)
const data = await response.json()
return data.response || ""
}
/** Build a structured prompt for the AI model, including file path, error message, and surrounding code context. */
function buildRepairPrompt(filePath, errorMessage, codeContext) {
return `You are a TypeScript repair agent. Fix the following error.
FILE: ${filePath}
ERROR: ${errorMessage}
CODE CONTEXT (line ${codeContext.contextLine + 1} is the error line):
\`\`\`typescript
${codeContext.content}
\`\`\`
Respond with ONLY the fix JSON in this exact format:
{"fix": "the exact replacement code for the error line(s)", "confidence": 0.0-1.0, "explanation": "brief explanation"}
Rules:
- Keep the fix minimal and precise
- Only change what's necessary to fix the error
- If the error is a missing import, suggest the import statement
- If the error is a type mismatch, suggest the corrected code
- confidence < 0.7 will not be auto-applied
- If you cannot determine a fix, set confidence to 0`
}
/** Parse the AI's JSON response (handles optional markdown fences around the JSON block). */
function parseFixResponse(text) {
try {
// Extract JSON from response (it might have markdown fences)
const jsonMatch = text.match(/\{[\s\S]*"fix"[\s\S]*"confidence"[\s\S]*\}/)
if (!jsonMatch) return null
return JSON.parse(jsonMatch[0])
} catch {
return null
}
}
/** Apply a fix suggestion to disk, returning success/failure and a backup of the original content. */
function applyFix(filePath, fixSuggestion) {
if (!fixSuggestion || !fixSuggestion.fix) return { applied: false, reason: "No fix suggestion" }
try {
const original = readFileSync(filePath, "utf-8")
writeFileSync(filePath, fixSuggestion.fix, "utf-8")
return { applied: true, backup: original }
} catch (e) {
return { applied: false, reason: e.message }
}
}
// ── Breaking Change Escalation ─────────────────────────────────────
/** Heuristic check: if any fix touches imports, exports, or deletions, consider it a breaking change. */
function isBreakingChange(errors, fixes) {
for (const fix of fixes) {
if (!fix || !fix.fix) continue
if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) {
return true
}
}
return false
}
/** Log the breaking change, wait for the escalation timeout, then auto-apply. */
async function escalateBreakingChange(errors, fixes) {
const logFile = resolve(ROOT, "repair-escalation.log")
const timestamp = new Date().toISOString()
const entry = [
`=== Breaking Change Escalation @ ${timestamp} ===`,
`Errors: ${errors.map(e => `${e.file}:${e.line}: ${e.message}`).join("\n ")}`,
`Fixes: ${fixes.map(f => f?.fix?.slice(0, 200)).join("\n ")}`,
`Auto-apply in ${ESCALATION_TIMEOUT_MS / 60000} minutes or cancel with Ctrl+C`,
"========================================\n",
].join("\n")
appendFileSync(logFile, entry)
log(`Breaking change escalated — see ${logFile}`, "warn")
log(`Auto-applying in ${ESCALATION_TIMEOUT_MS / 60000} min (Ctrl+C to cancel)...`, "warn")
// Wait for timeout or user intervention
await new Promise(resolve => setTimeout(resolve, ESCALATION_TIMEOUT_MS))
log("Escalation timeout reached — applying fixes")
return true
}
// ── Git Auto-Commit ────────────────────────────────────────────────
/** Git commit all changes with a [bot]-prefixed message. Returns false on failure (non-blocking). */
function gitCommit(message) {
try {
execSync("git add -A", { stdio: "pipe", cwd: ROOT })
execSync(`git commit -m "[bot] ${message}"`, { stdio: "pipe", cwd: ROOT })
log(`Committed: [bot] ${message}`)
return true
} catch (e) {
log(`Git commit failed: ${e.message}`, "warn")
return false
}
}
// ── Main Repair Loop ───────────────────────────────────────────────
/** Single pass: run tsc, parse errors, ask AI for fixes, apply them, and verify. Returns result summary. */
async function repairOnce(doCommit = false) {
log("Running build check...")
const build = runTsc()
if (build.ok) {
log("No build errors found")
return { fixed: false, errors: [] }
}
const errors = parseErrors(build.output)
if (errors.length === 0) {
log("Could not parse build errors from output", "warn")
log(build.output.slice(0, 1000))
return { fixed: false, errors: [] }
}
log(`Found ${errors.length} error(s):`)
for (const e of errors.slice(0, 10)) {
const shortFile = e.file.replace(ROOT, "").replace(/^\//, "")
log(`${shortFile}:${e.line}:${e.column}${e.message}`)
}
const fixes = []
for (const error of errors) {
const context = readContext(error.file, error.line)
if (!context) {
log(`Cannot read ${error.file}`, "error")
continue
}
log(`Asking ${MODEL} to fix ${error.file}:${error.line}...`)
try {
const prompt = buildRepairPrompt(error.file, error.message, context)
const response = await queryOllama(prompt)
const fix = parseFixResponse(response)
if (fix && fix.confidence >= CONFIDENCE_THRESHOLD) {
const result = applyFix(error.file, fix)
if (result.applied) {
log(`${fix.explanation || "Applied fix"} (confidence: ${fix.confidence})`)
fixes.push(fix)
} else {
log(`Failed to apply fix: ${result.reason}`, "error")
}
} else {
log(`Low confidence (${fix?.confidence || 0}) or invalid response — skipping`, "warn")
}
} catch (e) {
log(`Ollama error: ${e.message}`, "error")
}
}
if (fixes.length === 0) {
log("No fixes could be applied", "error")
return { fixed: false, errors }
}
// Verify by re-running build
log("Verifying fixes...")
const verify = runTsc()
if (verify.ok) {
log("All fixes verified — build passes!")
if (doCommit) gitCommit(`Auto-fix: ${fixes.length} error(s) resolved`)
return { fixed: true, errors: [], fixes }
}
const remainingErrors = parseErrors(verify.output)
log(`${remainingErrors.length} error(s) remaining after fix`, "warn")
// Check for breaking changes
if (isBreakingChange(remainingErrors, fixes)) {
log("Breaking change detected — escalating", "warn")
const approved = await escalateBreakingChange(remainingErrors, fixes)
if (approved) {
if (doCommit) gitCommit(`Breaking change applied after escalation`)
return { fixed: true, errors: remainingErrors, fixes, escalated: true }
}
}
return { fixed: remainingErrors.length === 0, errors: remainingErrors, fixes }
}
/** Run up to maxIterations repair passes, stopping once all errors are resolved or no progress is made. */
async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) {
for (let i = 0; i < maxIterations; i++) {
log(`=== Repair iteration ${i + 1}/${maxIterations} ===`)
const result = await repairOnce(doCommit)
if (!result.fixed) {
if (result.errors?.length > 0) {
log(`${result.errors.length} error(s) remaining — run again for deeper fix`, "warn")
}
break
}
if (result.errors?.length === 0) break
}
log("Repair agent finished")
}
// ── Watch Mode ─────────────────────────────────────────────────────
/** Watch mode: monitor src/ for file changes and auto-repair errors. Falls back to polling if chokidar is unavailable. */
function watchMode() {
log("Watch mode active — monitoring src/ for changes...", "info")
// Try loading chokidar; if not installed, fall back to simple interval polling
const chokidar = (() => {
try {
return _require("chokidar")
} catch {
return null
}
})()
if (!chokidar) {
log("chokidar not available — using simple polling (5s interval)", "warn")
setInterval(async () => {
const result = await repairOnce(false)
if (result.fixed) log("Auto-fix applied")
}, 5000)
return
}
const watcher = chokidar.watch("src/**/*.{ts,tsx}", { cwd: ROOT, ignoreInitial: true })
let debounceTimer = null
watcher.on("change", async (filePath) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
log(`Change detected: ${filePath}`)
await repairLoop(3, false)
}, 1000)
})
log("Watching for changes...")
}
// ── Entry ──────────────────────────────────────────────────────────
const args = process.argv.slice(2)
const isWatch = args.includes("--watch") || args.includes("-w")
const isCI = args.includes("--ci") || args.includes("-c")
async function main() {
if (isWatch) {
watchMode()
} else {
await repairLoop(MAX_ITERATIONS, isCI)
}
}
main().catch((e) => {
console.error("Repair agent crashed:", e)
process.exit(1)
})
+58
View File
@@ -0,0 +1,58 @@
// ── Ollama Launcher ────────────────────────────────────────────────
// Checks if Ollama is already running. If not, finds the binary and
// starts it as a detached background process.
// Cross-platform: uses tasklist (Windows) or pgrep (Linux/Mac) to
// detect running process; uses where/which to find the binary.
import { execSync, spawn } from "node:child_process"
import { platform } from "node:os"
/** Check if Ollama is already running by querying the OS process list. */
function isRunning() {
try {
if (platform() === "win32") {
execSync('tasklist /FI "IMAGENAME eq ollama.exe" 2>nul | findstr ollama', { stdio: "pipe", timeout: 3000 })
} else {
execSync('pgrep -x ollama', { stdio: "pipe", timeout: 3000 })
}
return true
} catch {
return false
}
}
/** Locate the Ollama binary via PATH, then fall back to common install directories. */
function findOllama() {
if (platform() === "win32") {
// Try PATH first, then common install locations
try {
return execSync("where ollama", { encoding: "utf8", timeout: 3000 }).trim().split("\n")[0]
} catch {}
const local = `${process.env.LOCALAPPDATA}\\Programs\\Ollama\\ollama.exe`
const programFiles = `${process.env.PROGRAMFILES}\\Ollama\\ollama.exe`
if (local) try { execSync(`"${local}" --version`, { stdio: "ignore", timeout: 2000 }); return local } catch {}
if (programFiles) try { execSync(`"${programFiles}" --version`, { stdio: "ignore", timeout: 2000 }); return programFiles } catch {}
} else {
try { return execSync("which ollama", { encoding: "utf8", timeout: 3000 }).trim() } catch {}
}
return null
}
if (!isRunning()) {
const bin = findOllama()
if (!bin) {
console.error("Ollama not found. Install from https://ollama.com")
process.exit(1)
}
console.log("Starting Ollama...")
// Spawn detached so it outlives this script and the npm process
if (platform() === "win32") {
spawn(bin, ["serve"], { stdio: "ignore", detached: true, windowsHide: true }).unref()
} else {
spawn(bin, ["serve"], { stdio: "ignore", detached: true }).unref()
}
// Give it a moment to start listening before we return control
execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
} else {
console.log("Ollama already running")
}
+405
View File
@@ -0,0 +1,405 @@
// ── Module Generator ─────────────────────────────────────────────────
// Generates missing internal modules from templates.
// Supports built-in templates and custom .setup-templates/ directory.
// Part of the self-healing setup pipeline — invoked when tsc reports
// TS2307 errors for @/ imports that don't exist yet.
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, "..", "..");
const SETUP_TEMPLATES_DIR = path.join(ROOT, ".setup-templates");
const REGISTRY_PATH = path.join(SETUP_TEMPLATES_DIR, "registry.json");
const TEMPLATES_DIR = path.join(SETUP_TEMPLATES_DIR, "templates");
/** Maps internal import paths (e.g. "data/stickers") to their template file and optional params. */
export interface TemplateRegistry {
[importPath: string]: {
template: string;
params?: Record<string, unknown>;
generator?: string; // optional custom generator function name
};
}
/** Result of a single module generation attempt. */
export interface GenerationResult {
success: boolean;
filePath: string;
internalPath: string;
message?: string;
error?: string;
}
/** Maps glob patterns (e.g. "@/hooks/*") to fallback template file names. */
export interface FallbackRegistry {
[pattern: string]: string;
}
/** Load the template registry from .setup-templates/registry.json, merging with built-in templates. */
export function loadRegistry(): { templates: TemplateRegistry; fallbacks: FallbackRegistry } {
// Built-in mappings for the most commonly imported modules
const builtInTemplates: TemplateRegistry = {
"data/stickers": { template: "stickers.ts" },
"data/constants": { template: "constants.ts" },
"lib/utils": { template: "utils.ts" },
"types/index": { template: "types-index.ts", params: { inferFromUsage: true } },
};
// Directory-level catch-all fallbacks for unknown imports
const builtInFallbacks: FallbackRegistry = {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts",
};
// Merge in any custom templates from the .setup-templates directory
try {
if (fs.existsSync(REGISTRY_PATH)) {
const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
const custom = JSON.parse(content);
return {
templates: { ...builtInTemplates, ...(custom.templates || {}) },
fallbacks: { ...builtInFallbacks, ...(custom.fallbackTemplates || {}) },
};
}
} catch {
// Use built-in
}
return { templates: builtInTemplates, fallbacks: builtInFallbacks };
}
/** Match an internal path (e.g. "data/stickers") against glob fallback patterns. Converts glob to regex internally. */
export function matchFallback(internalPath: string, fallbacks: FallbackRegistry): string | undefined {
for (const [pattern, template] of Object.entries(fallbacks)) {
// Convert glob pattern (e.g. "@/hooks/*") to a regular expression
const regexStr = "^" + pattern
.replace(/\//g, "\\/")
.replace(/\./g, "\\.")
.replace(/\*/g, ".*")
.replace(/[$^+(){}[\]|]/g, "\\$&")
+ "$";
const regex = new RegExp(regexStr);
if (regex.test(internalPath)) {
return template;
}
}
return undefined;
}
/** Render a template string by replacing {{ paramName }} placeholders with actual values. */
function renderTemplate(templateContent: string, params: Record<string, unknown> = {}): string {
let result = templateContent;
for (const [key, value] of Object.entries(params)) {
const regex = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g");
result = result.replace(regex, String(value));
}
return result;
}
/** Get template content — first tries the custom .setup-templates/templates dir, then falls back to built-in embedded templates. */
function getTemplateContent(templateName: string): string {
// Check custom templates first
const customPath = path.join(TEMPLATES_DIR, templateName);
if (fs.existsSync(customPath)) {
return fs.readFileSync(customPath, "utf-8");
}
// Built-in templates (embedded in the script — no external file needed)
const builtInTemplates: Record<string, string> = {
"stickers.ts": `// ── Sticker Packs ─────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
export interface Sticker {
id: string;
name: string;
emoji?: string;
svg?: string;
}
export interface StickerPack {
id: string;
name: string;
stickers: Sticker[];
}
export function getStickerPacks(): StickerPack[] {
return [
{
id: "reactions",
name: "Reactions",
stickers: [
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
{ id: "heart", name: "Heart", emoji: "❤️" },
{ id: "laugh", name: "Laugh", emoji: "😂" },
{ id: "wow", name: "Wow", emoji: "😮" },
{ id: "sad", name: "Sad", emoji: "😢" },
{ id: "angry", name: "Angry", emoji: "😡" },
{ id: "clap", name: "Clap", emoji: "👏" },
{ id: "fire", name: "Fire", emoji: "🔥" },
{ id: "100", name: "100", emoji: "💯" },
{ id: "eyes", name: "Eyes", emoji: "👀" },
{ id: "pray", name: "Pray", emoji: "🙏" },
],
},
{
id: "animals",
name: "Animals",
stickers: [
{ id: "cat", name: "Cat", emoji: "🐱" },
{ id: "dog", name: "Dog", emoji: "🐶" },
{ id: "panda", name: "Panda", emoji: "🐼" },
{ id: "fox", name: "Fox", emoji: "🦊" },
{ id: "bear", name: "Bear", emoji: "🐻" },
{ id: "koala", name: "Koala", emoji: "🐨" },
{ id: "tiger", name: "Tiger", emoji: "🐯" },
{ id: "lion", name: "Lion", emoji: "🦁" },
{ id: "monkey", name: "Monkey", emoji: "🐵" },
{ id: "frog", name: "Frog", emoji: "🐸" },
{ id: "penguin", name: "Penguin", emoji: "🐧" },
{ id: "bird", name: "Bird", emoji: "🐦" },
],
},
{
id: "food",
name: "Food & Drink",
stickers: [
{ id: "pizza", name: "Pizza", emoji: "🍕" },
{ id: "burger", name: "Burger", emoji: "🍔" },
{ id: "taco", name: "Taco", emoji: "🌮" },
{ id: "sushi", name: "Sushi", emoji: "🍣" },
{ id: "ramen", name: "Ramen", emoji: "🍜" },
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
{ id: "cake", name: "Cake", emoji: "🎂" },
{ id: "coffee", name: "Coffee", emoji: "☕" },
{ id: "beer", name: "Beer", emoji: "🍺" },
{ id: "wine", name: "Wine", emoji: "🍷" },
{ id: "donut", name: "Donut", emoji: "🍩" },
{ id: "cookie", name: "Cookie", emoji: "🍪" },
],
},
];
};
export function getStickerPacks(): StickerPack[] {
return [
// reactions, animals, food packs defined above
// (inline for brevity - full packs in template file)
];
}
`,
"constants.ts": `// ── App Constants ────────────────────────────────────────────────────
// Auto-generated by self-healing setup.
export const APP_NAME = "CoastIT CRM";
export const APP_VERSION = "1.0.0";
export const LEAD_STATUSES = {
new: { label: "New", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-yellow-500" },
qualified: { label: "Qualified", color: "bg-purple-500" },
proposal: { label: "Proposal", color: "bg-orange-500" },
won: { label: "Won", color: "bg-green-500" },
lost: { label: "Lost", color: "bg-red-500" },
} as const;
export const USER_ROLES = {
super_admin: { label: "Super Admin", level: 1 },
admin: { label: "Admin", level: 2 },
sales: { label: "Sales", level: 3 },
dev: { label: "Developer", level: 4 },
} as const;
export const EVENT_TYPES = [
"meeting",
"call",
"email",
"task",
"note",
] as const;
`,
"utils.ts": `// ── Utility Functions ────────────────────────────────────────────────
// Auto-generated by self-healing setup.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
...options,
});
}
export function formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length - 3) + "...";
}
export function generateId(): string {
return crypto.randomUUID();
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
`,
"types-index.ts": `// ── Type Definitions ──────────────────────────────────────────────────
// Auto-generated by self-healing setup.
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;
createdAt: string;
}
export interface Lead {
id: string;
companyName: string;
contactName: string;
email: string;
phone: string;
source: string;
description: string;
status: LeadStatus;
assignedUserId: string | null;
assignedUser: User | null;
createdAt: string;
updatedAt: string;
}
export type LeadStatus = "new" | "contacted" | "qualified" | "proposal" | "won" | "lost";
export interface DashboardStats {
totalLeads: number;
newLeads: number;
contactedLeads: number;
qualifiedLeads: number;
proposalLeads: number;
wonLeads: number;
lostLeads: number;
conversionRate: number;
}
`,
};
return builtInTemplates[templateName] || "";
}
/** Generate a single missing module: resolve template, render parameters, write to src/. */
export function generateModule(
internalPath: string,
templates: TemplateRegistry,
fallbacks: FallbackRegistry
): GenerationResult {
// Look up exact template first, then fall back to directory-level glob patterns
let entry = templates[internalPath];
let templateFile = entry?.template;
if (!entry) {
const fallbackTemplate = matchFallback(internalPath, fallbacks);
if (fallbackTemplate) {
templateFile = fallbackTemplate;
entry = { template: fallbackTemplate };
}
}
if (!templateFile) {
return {
success: false,
filePath: "",
internalPath,
error: `No template registered for @/${internalPath}`,
};
}
const templateContent = getTemplateContent(templateFile);
if (!templateContent) {
return {
success: false,
filePath: "",
internalPath,
error: `Template not found: ${entry.template}`,
};
}
// Replace template parameter placeholders with actual values
const rendered = renderTemplate(templateContent, entry.params || {});
const filePath = path.join(ROOT, "src", internalPath + (internalPath.endsWith(".ts") ? "" : ".ts"));
// Ensure directory exists before writing
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Write file
try {
fs.writeFileSync(filePath, rendered, "utf-8");
return {
success: true,
filePath,
internalPath,
message: `Generated @/${internalPath}`,
};
} catch (e) {
return {
success: false,
filePath,
internalPath,
error: `Failed to write file: ${e instanceof Error ? e.message : String(e)}`,
};
}
}
/** Generate all missing modules for a list of internal paths, logging each result. */
export function generateAll(internalPaths: string[]): GenerationResult[] {
const { templates, fallbacks } = loadRegistry();
const results: GenerationResult[] = [];
for (const internalPath of internalPaths) {
const result = generateModule(internalPath, templates, fallbacks);
results.push(result);
if (result.success) {
console.log(`${result.message}`);
} else {
console.error(` ✗ Failed @/${internalPath}: ${result.error}`);
}
}
return results;
}
+93
View File
@@ -0,0 +1,93 @@
// ── TypeScript Error Parser ──────────────────────────────────────────
// Parses `tsc --noEmit` or `npm run build` output to extract
// "Module not found: Can't resolve '@/path/to/module'" errors.
// Returns actionable missing module info for the generator.
// Used by both setup.mjs (self-heal) and code-repair-agent.mjs.
export interface MissingModule {
/** The import path that failed, e.g. "@/data/stickers" */
importPath: string;
/** The file that contains the failing import */
importerFile: string;
/** Line number in importer file */
line: number;
/** Column number */
column: number;
/** Full error message for context */
message: string;
/** Whether this is an internal (@/...) module we can generate */
isInternal: boolean;
/** The normalized internal path, e.g. "data/stickers" */
internalPath?: string;
}
/** Parse TypeScript compiler output for missing module errors (TS2307 + webpack-style "Can't resolve"). */
export function parseMissingModules(tscOutput: string): MissingModule[] {
const results: MissingModule[] = [];
// Pattern: error TS2307: Cannot find module '@/data/stickers' or its corresponding type declarations.
// File: src/components/chats/media-picker.tsx:10:1
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'\s+or its corresponding type declarations\.\s*(?:\n\s*(.*?):(\d+):(\d+))?/g;
let match;
while ((match = errorRegex.exec(tscOutput)) !== null) {
const importPath = match[1];
const importerFile = match[2] || "";
const line = parseInt(match[3] || "0", 10);
const column = parseInt(match[4] || "0", 10);
// Also handle: Module not found: Error: Can't resolve '@/data/stickers' in 'C:\...\src\components\chats'
const altRegex = /Module not found: Error: Can't resolve\s+'([^']+)'\s+in\s+'([^']+)'/g;
let altMatch;
let altImporter = importerFile;
while ((altMatch = altRegex.exec(tscOutput)) !== null) {
if (altMatch[1] === importPath) {
altImporter = altMatch[2];
break;
}
}
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/");
let internalPath: string | undefined;
if (isInternal) {
internalPath = importPath.replace(/^[@~]\//, "");
}
results.push({
importPath,
importerFile: altImporter,
line,
column,
message: `Cannot find module '${importPath}'`,
isInternal,
internalPath,
});
}
// Deduplicate by importPath + importerFile
const seen = new Set<string>();
return results.filter((m) => {
const key = `${m.importPath}|${m.importerFile}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** Filter to only internal (@/... or ~/...) modules we can auto-generate from templates. */
export function filterGeneratable(modules: MissingModule[]): MissingModule[] {
return modules.filter((m) => m.isInternal && m.internalPath);
}
/** Group missing modules by their normalized internal path (deduplicates same module imported from multiple files). */
export function groupByInternalPath(modules: MissingModule[]): Map<string, MissingModule[]> {
const groups = new Map<string, MissingModule[]>();
for (const m of modules) {
if (!m.internalPath) continue;
const existing = groups.get(m.internalPath) || [];
existing.push(m);
groups.set(m.internalPath, existing);
}
return groups;
}
+32
View File
@@ -0,0 +1,32 @@
// ── Browser Opener ─────────────────────────────────────────────────
// Opens the splash page in the user's default browser after an 8-second
// delay. The delay gives all services time to start before the user
// sees the loading screen.
// Cross-platform: uses start (Windows), open (Mac), or xdg-open (Linux).
import { execSync } from "node:child_process"
import { platform } from "node:os"
const url = process.argv[2] || "http://localhost:3001/splash"
/** Promise-based sleep helper for the startup delay. */
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function main() {
// Wait for the AI server, scraper, and frontend to finish booting
await sleep(8000)
try {
// Platform-specific command to open the default browser
if (platform() === "win32") {
execSync(`start "" "${url}"`, { stdio: "ignore", timeout: 5000 })
} else if (platform() === "darwin") {
execSync(`open "${url}"`, { stdio: "ignore", timeout: 5000 })
} else {
execSync(`xdg-open "${url}"`, { stdio: "ignore", timeout: 5000 })
}
} catch (e) {
console.error("Failed to open browser:", e.message)
}
}
main()
+69
View File
@@ -0,0 +1,69 @@
// ── Dependency Check ─────────────────────────────────────────────────
// Verifies all packages listed in package.json are installed.
// Auto-runs npm install if any are missing — zero manual setup steps.
import { readFileSync, existsSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { execSync } from "node:child_process"
import { platform } from "node:os"
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, "..")
try {
// Read package.json and check each dependency's package.json in node_modules
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"))
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
const missing = Object.keys(allDeps).filter(name => {
const pkgDir = resolve(root, "node_modules", name)
return !existsSync(resolve(pkgDir, "package.json"))
})
if (missing.length > 0) {
console.log(`\n${missing.length} package(s) missing: ${missing.join(", ")}`)
console.log("Installing...\n")
execSync("npm install --no-audit --no-fund", { cwd: root, stdio: "inherit", timeout: 120000 })
console.log("Dependencies installed\n")
}
} catch {
// If package.json read or resolution fails, skip silently
}
// ── Port Precheck ──────────────────────────────────────────────────
// Kills any existing processes on ports 3001, 3006, 3007, 3008.
// These are the AI server, Next.js frontend, Signaling server, and
// Python scraper respectively.
// Runs before anything else starts to avoid EADDRINUSE errors.
const PORTS = [3001, 3006, 3007, 3008]
if (platform() === "win32") {
// Windows: use netstat + findstr to find listening PIDs, then taskkill
for (const port of PORTS) {
try {
const out = execSync(`netstat -ano | findstr "LISTENING" | findstr ":${port} "`, { encoding: "utf8", timeout: 5000 })
const lines = out.trim().split("\n").filter(Boolean)
for (const line of lines) {
// Parse the last column of netstat output — the PID
const parts = line.trim().split(/\s+/)
const pid = parts[parts.length - 1]
if (pid) {
try { execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore", timeout: 3000 }); console.log(`Freed port ${port} (PID ${pid})`) } catch {}
}
}
} catch {}
}
} else {
// Linux/Mac: use lsof -ti to find PIDs, then kill -9
for (const port of PORTS) {
try {
const pid = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf8", timeout: 5000 }).trim()
if (pid) {
execSync(`kill -9 ${pid}`, { stdio: "ignore", timeout: 3000 })
console.log(`Freed port ${port} (PID ${pid})`)
}
} catch {}
}
}
+195
View File
@@ -0,0 +1,195 @@
// ── Automated Database Migration Runner ──────────────────────────────
// Reads .env.local for DATABASE_URL, tracks applied migrations in
// a _migrations table, and runs unapplied .sql files via psql.
// Runs automatically on npm run dev and npm run setup.
// ============================================================================
import { readFileSync, existsSync } from "node:fs"
import { execSync } from "node:child_process"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, "..")
// ── Load .env.local into process.env ─────────────────────────────────
// Simple key=value parser (no dotenv dependency needed).
// Handles quoted values and skips comments / blank lines.
const envPath = resolve(ROOT, ".env.local")
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) continue
const eq = trimmed.indexOf("=")
if (eq === -1) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
// Strip matching surrounding quotes (single or double)
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
// Don't override already-set env vars
if (!process.env[key]) process.env[key] = value
}
}
const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) {
console.log(" ~ DATABASE_URL not set — skipping migrations")
process.exit(0)
}
// ── Find psql ────────────────────────────────────────────────────────
// Check PATH first, then probe common PostgreSQL v1417 install paths.
function findPsql() {
try {
execSync("psql --version", { stdio: "pipe", timeout: 5000 })
return "psql"
} catch {}
const candidates = [
"C:\\Program Files\\PostgreSQL\\16\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\15\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\14\\bin\\psql.exe",
]
for (const p of candidates) {
if (existsSync(p)) return `"${p}"`
}
return null
}
const PSQL = findPsql()
if (!PSQL) {
console.log(" ~ psql not found — skipping migrations (install PostgreSQL client tools)")
process.exit(0)
}
// ── Database connection (for tracking table) ─────────────────────────
const require = createRequire(import.meta.url)
const { Pool } = require("pg")
let pool
try {
pool = new Pool({
connectionString: DATABASE_URL,
max: 1,
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 10000,
})
await pool.query("SELECT 1")
} catch {
console.log(" ~ Database not available — skipping migrations")
process.exit(0)
}
// ── Migration logic ──────────────────────────────────────────────────
/** Run all unapplied database migrations in the order defined by run_all.sql. */
async function runMigrations() {
// 1. Ensure the _migrations tracking table exists (idempotent)
await pool.query(`
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
// 2. Determine file order from run_all.sql (authoritative source of truth)
const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql")
const runAllContent = readFileSync(runAllPath, "utf-8")
const fileOrder = []
for (const line of runAllContent.split("\n")) {
// `\i filename.sql` is the psql include directive
const match = line.match(/\\i\s+(\S+\.sql)/)
if (match) fileOrder.push(match[1])
}
if (fileOrder.length === 0) {
console.log(" ~ No migration files found in run_all.sql")
return
}
// 3. Get already-applied files
let appliedSet
try {
const { rows: applied } = await pool.query("SELECT filename FROM _migrations ORDER BY filename")
appliedSet = new Set(applied.map((r) => r.filename))
} catch {
appliedSet = new Set()
}
// 4. Build psql command base with ON_ERROR_STOP=1 so psql aborts on first error
const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1`
// 5. Run unapplied files in order.
// psql -v ON_ERROR_STOP=1 aborts on the first error with exit code 3.
// If the error is "already exists", the file was already applied before
// tracking existed — mark it as applied and continue.
// Other errors are genuine failures — abort.
const ALREADY_APPLIED_PATTERNS = [
"already exists",
"duplicate key",
"cannot insert multiple commands into a prepared statement",
]
let count = 0
for (const file of fileOrder) {
// Skip files already recorded in the _migrations table
if (appliedSet.has(file)) continue
const filePath = resolve(ROOT, "database", "migrations", file)
if (!existsSync(filePath)) {
console.error(` ✗ Migration file not found: ${file}`)
process.exit(1)
}
let stderr = ""
try {
const result = execSync(`${psqlCmd} -f "${filePath}"`, {
timeout: 30000,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
})
// psql outputs notices/warnings to stderr even on success — capture for diagnostics
stderr = result.stderr || ""
} catch (err) {
// Distinguish between "already applied" (harmless) and genuine failures
const msg = err.stderr || err.stdout || err.message
const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p))
if (isAlreadyApplied) {
// File was applied before tracking existed — record it and continue
await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file])
console.log(` ~ Already applied: ${file}`)
count++
continue
}
console.error(` ✗ Migration failed: ${file}`)
console.error(` ${msg.split("\n").slice(0, 5).join("\n ")}`)
process.exit(1)
}
// Record successful migration
await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file])
console.log(` ✓ Applied: ${file}`)
count++
}
if (count === 0) {
console.log(" ~ All migrations up to date")
}
}
// ── Run ──────────────────────────────────────────────────────────────
try {
await runMigrations()
} finally {
await pool.end()
}
+50
View File
@@ -0,0 +1,50 @@
// ── Python Runner ──────────────────────────────────────────────────
// Detects the system's Python executable (python vs python3) and runs
// a given script with arguments. Used by the dev:browser-use npm script.
// Avoids shell:true — spawns Python directly with its full path.
// Cross-platform: uses where (Windows) or which (Linux/Mac).
import { execSync, spawn } from "node:child_process"
import { platform } from "node:os"
import { statSync } from "node:fs"
/** Locate the Python 3 executable. Checks common install paths first, then the system PATH. */
function detectPython() {
// Pre-check common Windows install directories to avoid PATH lookup
const commonPaths = [
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python313\\python.exe`,
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python312\\python.exe`,
`${process.env.ProgramFiles}\\Python313\\python.exe`,
`${process.env.ProgramFiles}\\Python312\\python.exe`,
]
for (const p of commonPaths) {
try {
statSync(p)
return p
} catch {}
}
// Fall back to PATH resolution (prefer python3 on Unix)
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) {
try {
const out = execSync(platform() === "win32" ? `where ${cmd}` : `which ${cmd}`, { encoding: "utf8", timeout: 5000 })
const path = out.trim().split("\n")[0].replace(/\r$/, "")
if (path) return path
} catch {}
}
console.error("Python not found. Install Python 3 from https://python.org")
process.exit(1)
}
const PYTHON = detectPython()
const script = process.argv[2]
const args = process.argv.slice(3)
if (!script) {
console.error("Usage: node run-python.mjs <script> [args...]")
process.exit(1)
}
// Spawn Python with inherited stdio so the script's output is visible in real-time
const proc = spawn(PYTHON, [script, ...args], { stdio: "inherit" })
proc.on("exit", (code) => process.exit(code ?? 1))
+225
View File
@@ -0,0 +1,225 @@
// ── One-command Setup ──────────────────────────────────────────────
// Run via: npm run setup
// Does the following in order:
// 1. npm install (Node.js dependencies)
// 2. pip install -r requirements.txt (Python dependencies)
// 3. playwright install firefox chromium (Playwright browsers)
// 4. Copies .env.example to .env.local if not exists
// 5. --self-heal flag: runs build check + auto-generates missing modules
//
// All steps are cross-platform (Windows, Mac, Linux).
import { execSync } from "node:child_process"
import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
import { platform } from "node:os"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
// Shell command separator differs per platform (Windows uses &, POSIX uses ;)
const SEP = platform() === "win32" ? "&" : ";"
const SELF = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SELF, "..")
// ── Helpers ────────────────────────────────────────────────────────
/** Try known Python command names until one succeeds, then exit if none found. */
function detectPython() {
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) {
try {
execSync(`${cmd} --version`, { stdio: "pipe", timeout: 5000 })
return cmd
} catch {}
}
console.error("Python not found. Install Python 3 from https://python.org")
process.exit(1)
}
/** Detect pip; fall back to `python -m pip` if no standalone pip binary exists. */
function detectPip(python) {
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
for (const cmd of candidates) {
try {
execSync(`${cmd} --version`, { stdio: "pipe", timeout: 5000 })
return cmd
} catch {}
}
return `${python} -m pip`
}
/** Run a shell command with label output. Exits on failure unless opts.optional is set. */
function run(cmd, label, opts = {}) {
console.log(`\n── ${label} ──`)
try {
execSync(cmd, { stdio: "inherit", timeout: opts.timeout || 120000, cwd: opts.cwd || ROOT })
} catch (e) {
if (opts.optional) {
console.log(` ~ Skipped (${e.message})`)
return false
}
console.error(` ✗ Failed: ${e.message}`)
process.exit(1)
}
return true
}
// ── Self-Healing Module Registry ───────────────────────────────────
// Maps internal import paths (like @/data/stickers) to template files.
// Supports custom templates in .setup-templates/ that override built-ins.
// Fallback patterns catch whole directories (e.g. @/hooks/* → hook-generic.ts).
function loadRegistry() {
const registryPath = resolve(ROOT, ".setup-templates", "registry.json")
const templatesDir = resolve(ROOT, ".setup-templates", "templates")
// Hard-coded templates for commonly imported modules
const builtInTemplates = {
"data/stickers": { template: "stickers.ts" },
"data/constants": { template: "constants.ts" },
"lib/utils": { template: "utils.ts" },
"types/index": { template: "types-index.ts" },
"hooks/use-media": { template: "hooks/use-media.ts" },
"hooks/use-debounce": { template: "hooks/use-debounce.ts" },
"hooks/use-local-storage": { template: "hooks/use-local-storage.ts" },
}
// Catch-all fallbacks for entire directory prefixes
const builtInFallbacks = {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts",
}
let templates = { ...builtInTemplates }
let fallbacks = { ...builtInFallbacks }
// Merge custom templates from .setup-templates/registry.json (if it exists)
try {
if (existsSync(registryPath)) {
const custom = JSON.parse(readFileSync(registryPath, "utf-8"))
templates = { ...templates, ...(custom.templates || {}) }
fallbacks = { ...fallbacks, ...(custom.fallbackTemplates || {}) }
}
} catch {}
const templateCache = {}
/** Read a template file, checking the custom templates dir first, then hooks subdirectory. */
function getTemplate(templateName) {
if (templateCache[templateName]) return templateCache[templateName]
const candidatePaths = [
resolve(templatesDir, templateName),
resolve(templatesDir, "hooks", templateName.replace("hooks/", "")),
]
for (const p of candidatePaths) {
if (existsSync(p)) {
const content = readFileSync(p, "utf-8")
templateCache[templateName] = content
return content
}
}
return ""
}
/** Convert a glob pattern (e.g. @/hooks/*) to a regex and test against the internal path. */
function matchFallback(internalPath) {
for (const [pattern, templateFile] of Object.entries(fallbacks)) {
const regexStr = "^" + pattern
.replace(/\//g, "\\/").replace(/\./g, "\\.")
.replace(/\*/g, ".*")
.replace(/[$^+(){}[\]|]/g, "\\$&") + "$"
const regex = new RegExp(regexStr)
if (regex.test(internalPath)) return templateFile
}
return null
}
/** Generate (or regenerate) a single module file from its template. */
function generateModule(internalPath) {
let templateFile = templates[internalPath]?.template
if (!templateFile) templateFile = matchFallback(internalPath)
if (!templateFile) return { success: false, error: `No template for @/${internalPath}` }
const content = getTemplate(templateFile)
if (!content) return { success: false, error: `Template file not found: ${templateFile}` }
const ext = templateFile.endsWith(".tsx") ? ".tsx" : ".ts"
const filePath = resolve(ROOT, "src", internalPath + ext)
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, content, "utf-8")
return { success: true, filePath, message: `Generated @/${internalPath}` }
}
return { generateModule }
}
/** Parse `tsc --noEmit` or `npm run build` output for missing-module errors, returning unique import paths. */
function parseMissingModules(buildOutput) {
const results = []
const seen = new Set()
// Pattern: error TS2307: Cannot find module '@/path' or its corresponding type declarations.
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'/g
let match
while ((match = errorRegex.exec(buildOutput)) !== null) {
const importPath = match[1]
if (!seen.has(importPath)) {
seen.add(importPath)
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/")
const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined
results.push({ importPath, isInternal, internalPath })
}
}
// Also catch webpack-style: Module not found: Error: Can't resolve '@/path'
const webpackRegex = /Module not found: Error: Can't resolve\s+'([^']+)'/g
while ((match = webpackRegex.exec(buildOutput)) !== null) {
const importPath = match[1]
if (!seen.has(importPath)) {
seen.add(importPath)
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/")
const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined
results.push({ importPath, isInternal, internalPath })
}
}
return results
}
// ── Main ───────────────────────────────────────────────────────────
// Detect Python and pip executables before running any steps
const PY = detectPython()
const PIP = detectPip(PY)
console.log("=== CoastIT CRM Setup ===\n")
// Step 1 — Install all Node.js dependencies (includes next, react, etc.)
run("npm install", "Installing Node.js dependencies")
// Step 2 — Install Python deps for the browser-use service (Playwright-based scraper)
run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies")
// Step 3 — Download Playwright browser binaries for scraper automation
run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers")
// Step 4 — Bootstrap .env.local from the example template (won't overwrite existing)
if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── Creating .env.local ──")
copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local"))
console.log(" ✓ Created .env.local from .env.example — edit it with your settings")
} else {
console.log("\n── .env.local already exists, skipping ──")
}
// Step 5 — Print post-setup instructions
console.log("\n── Final Steps ──")
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
console.log(" 3. Edit .env.local with your settings")
console.log(" 4. Run: npm run db:migrate (apply database migrations)")
console.log(" 5. Run: npm run dev")
console.log("\n=== Setup complete! ===")
+87 -2
View File
@@ -1,20 +1,44 @@
// ── WebRTC Signaling Server ─────────────────────────────────────────
// Provides real-time signaling for the CRM chat system using Socket.IO:
// - JWT-based authentication middleware
// - Online user presence tracking (online/offline)
// - Peer-to-peer call signaling (offer/answer/ICE candidates)
// - Multi-participant room-based WebRTC (join/leave/relay)
// - Message deletion broadcast to conversation participants
//
// Clients authenticate via socket handshake auth token; unauthenticated
// users can still join rooms as anonymous participants.
import { createServer } from "http" import { createServer } from "http"
import { Server } from "socket.io" import { Server } from "socket.io"
import { SignJWT, jwtVerify } from "jose" import { SignJWT, jwtVerify } from "jose"
import pg from "pg" import pg from "pg"
// ── Configuration ─────────────────────────────────────────────────
const PORT = process.env.SIGNALING_PORT || 3007 const PORT = process.env.SIGNALING_PORT || 3007
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026") const rawSecret = process.env.JWT_SECRET
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm" if (!rawSecret) throw new Error("JWT_SECRET environment variable is required")
const JWT_SECRET = new TextEncoder().encode(rawSecret)
const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) throw new Error("DATABASE_URL environment variable is required")
// ── Database pool ─────────────────────────────────────────────────
const pool = new pg.Pool({ connectionString: DATABASE_URL }) const pool = new pg.Pool({ connectionString: DATABASE_URL })
// ── HTTP + Socket.IO server ───────────────────────────────────────
const httpServer = createServer() const httpServer = createServer()
const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } }) const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } })
// ── In-memory state ───────────────────────────────────────────────
// onlineUsers maps userId -> socketId for presence tracking.
// rooms maps roomId -> array of { socketId, id, label } participants.
const onlineUsers = new Map() const onlineUsers = new Map()
const rooms = new Map() const rooms = new Map()
// ── Authentication middleware ─────────────────────────────────────
// Verifies the JWT token from handshake auth. If valid, attaches
// userId and role to the socket's data bag. Unauthenticated sockets
// proceed with null user info.
io.use((socket, next) => { io.use((socket, next) => {
const token = socket.handshake.auth?.token const token = socket.handshake.auth?.token
if (token) { if (token) {
@@ -36,15 +60,25 @@ io.use((socket, next) => {
} }
}) })
// ── Connection handler ────────────────────────────────────────────
// Registers authenticated users as online, then wires up all
// per-socket event listeners for signaling and presence.
io.on("connection", (socket) => { io.on("connection", (socket) => {
const userId = socket.data.userId const userId = socket.data.userId
// Mark the user online and broadcast to other clients
if (userId) { if (userId) {
onlineUsers.set(userId, socket.id) onlineUsers.set(userId, socket.id)
socket.broadcast.emit("user:online", { userId }) socket.broadcast.emit("user:online", { userId })
} }
// ── Authenticated event handlers ──────────────────────────────
// Only users with a valid JWT token may use these features.
if (userId) { if (userId) {
// user:lookup — looks up a user by phone number in the database.
// Fires when the client searches for a contact to start a call.
// Returns { found, user } or { found: false }.
socket.on("user:lookup", async ({ phone }, callback) => { socket.on("user:lookup", async ({ phone }, callback) => {
try { try {
const result = await pool.query( const result = await pool.query(
@@ -76,6 +110,9 @@ io.on("connection", (socket) => {
} }
}) })
// call:offer — relays an SDP offer to the target user.
// Fires when the caller initiates a WebRTC call.
// callerInfo is fetched from the DB if not supplied.
socket.on("call:offer", async ({ to, sdp, callerInfo }) => { socket.on("call:offer", async ({ to, sdp, callerInfo }) => {
const targetSocketId = onlineUsers.get(to) const targetSocketId = onlineUsers.get(to)
if (targetSocketId) { if (targetSocketId) {
@@ -109,6 +146,8 @@ io.on("connection", (socket) => {
} }
}) })
// call:answer — relays an SDP answer back to the caller.
// Fires when the callee accepts the incoming call.
socket.on("call:answer", ({ to, sdp }) => { socket.on("call:answer", ({ to, sdp }) => {
const targetSocketId = onlineUsers.get(to) const targetSocketId = onlineUsers.get(to)
if (targetSocketId) { if (targetSocketId) {
@@ -116,6 +155,8 @@ io.on("connection", (socket) => {
} }
}) })
// call:ice-candidate — relays ICE candidates between peers.
// Fires during connection negotiation for NAT traversal.
socket.on("call:ice-candidate", ({ to, candidate }) => { socket.on("call:ice-candidate", ({ to, candidate }) => {
const targetSocketId = onlineUsers.get(to) const targetSocketId = onlineUsers.get(to)
if (targetSocketId) { if (targetSocketId) {
@@ -123,6 +164,7 @@ io.on("connection", (socket) => {
} }
}) })
// call:end — notifies the remote peer that the call ended.
socket.on("call:end", ({ to }) => { socket.on("call:end", ({ to }) => {
const targetSocketId = onlineUsers.get(to) const targetSocketId = onlineUsers.get(to)
if (targetSocketId) { if (targetSocketId) {
@@ -130,6 +172,7 @@ io.on("connection", (socket) => {
} }
}) })
// call:reject — tells the caller the callee rejected the call.
socket.on("call:reject", ({ to }) => { socket.on("call:reject", ({ to }) => {
const targetSocketId = onlineUsers.get(to) const targetSocketId = onlineUsers.get(to)
if (targetSocketId) { if (targetSocketId) {
@@ -137,14 +180,46 @@ io.on("connection", (socket) => {
} }
}) })
// call:busy — tells the caller the callee is on another call.
socket.on("call:busy", ({ to }) => { socket.on("call:busy", ({ to }) => {
const targetSocketId = onlineUsers.get(to) const targetSocketId = onlineUsers.get(to)
if (targetSocketId) { if (targetSocketId) {
io.to(targetSocketId).emit("call:busy", { from: userId }) io.to(targetSocketId).emit("call:busy", { from: userId })
} }
}) })
// message:deleted — broadcasts a deletion event to all other
// participants in the conversation. Looks up participant IDs
// from the database so only relevant clients are notified.
socket.on("message:deleted", async ({ conversationId, messageId, senderId }) => {
try {
const result = await pool.query(
`SELECT cp.user_id FROM conversation_participants cp
WHERE cp.conversation_id = $1 AND cp.user_id != $2`,
[conversationId, senderId || userId],
)
for (const row of result.rows) {
const targetSocketId = onlineUsers.get(row.user_id)
if (targetSocketId) {
io.to(targetSocketId).emit("message:deleted", { conversationId, messageId })
}
}
console.log(`[message:deleted] broadcast msg=${messageId} conv=${conversationId} by=${senderId || userId}`)
} catch {
console.warn(`[message:deleted] failed to broadcast msg=${messageId}`)
}
})
} }
// ── Room-based signaling (authenticated + anonymous) ──────────
// These events support multi-participant rooms used for features
// like screen sharing or group calls. Anonymous users are assigned
// an "anon-" prefix ID.
// room:join — adds the socket to a named room. When the first
// participant joins they receive "room:waiting". When the second
// joins the first is told to initiate an offer. Additional
// participants are notified normally.
socket.on("room:join", ({ roomId, displayName }) => { socket.on("room:join", ({ roomId, displayName }) => {
const participantId = userId || `anon-${socket.id}` const participantId = userId || `anon-${socket.id}`
const participantLabel = displayName || participantId const participantLabel = displayName || participantId
@@ -169,18 +244,24 @@ io.on("connection", (socket) => {
} }
}) })
// room:offer — relays an SDP offer to all other room participants.
socket.on("room:offer", ({ roomId, sdp }) => { socket.on("room:offer", ({ roomId, sdp }) => {
socket.to(roomId).emit("room:offer", { sdp }) socket.to(roomId).emit("room:offer", { sdp })
}) })
// room:answer — relays an SDP answer to all other room participants.
socket.on("room:answer", ({ roomId, sdp }) => { socket.on("room:answer", ({ roomId, sdp }) => {
socket.to(roomId).emit("room:answer", { sdp }) socket.to(roomId).emit("room:answer", { sdp })
}) })
// room:ice-candidate — relays ICE candidates to all other room participants.
socket.on("room:ice-candidate", ({ roomId, candidate }) => { socket.on("room:ice-candidate", ({ roomId, candidate }) => {
socket.to(roomId).emit("room:ice-candidate", { candidate }) socket.to(roomId).emit("room:ice-candidate", { candidate })
}) })
// room:leave — removes the socket from a room. Cleans up the
// in-memory room map and notifies remaining participants. Rooms
// are fully deleted when empty.
socket.on("room:leave", ({ roomId }) => { socket.on("room:leave", ({ roomId }) => {
socket.leave(roomId) socket.leave(roomId)
const participants = rooms.get(roomId) const participants = rooms.get(roomId)
@@ -196,6 +277,9 @@ io.on("connection", (socket) => {
} }
}) })
// disconnect — fires when the socket loses connection. Cleans up
// the user from all rooms they were in, removes them from the
// online users map, and broadcasts the offline event.
socket.on("disconnect", () => { socket.on("disconnect", () => {
for (const [roomId, participants] of rooms) { for (const [roomId, participants] of rooms) {
const idx = participants.findIndex(p => p.socketId === socket.id) const idx = participants.findIndex(p => p.socketId === socket.id)
@@ -216,6 +300,7 @@ io.on("connection", (socket) => {
}) })
}) })
// ── Start ─────────────────────────────────────────────────────────
httpServer.listen(PORT, () => { httpServer.listen(PORT, () => {
console.log(`[signaling] server running on port ${PORT}`) console.log(`[signaling] server running on port ${PORT}`)
}) })
+450 -68
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Loading CoastIT CRM</title> <title>Loading CoastIT CRM</title>
<style> <style>
/* ── Global Reset ──────────────────────────────────────────────── */
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
background: #0a0a1a; background: #0a0a1a;
@@ -18,7 +19,8 @@
overflow: hidden; overflow: hidden;
} }
/* Robot */ /* ── Robot Animation ────────────────────────────────────────────── */
/* Animated robot mascot that bobs, swings arms, and blinks while loading */
.robot { .robot {
position: relative; position: relative;
width: 120px; width: 120px;
@@ -128,7 +130,9 @@
100% { transform: translateY(-4px) rotate(8deg); } 100% { transform: translateY(-4px) rotate(8deg); }
} }
/* Status indicators */ /* ── Status Indicators ────────────────────────────────────────── */
/* Shows AI Server, Scraper, and Frontend status dots side by side.
Each transitions from dim → cyan (ready) or red (failed). */
.services { .services {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -207,7 +211,7 @@
color: #ef4444; color: #ef4444;
} }
/* Loading text */ /* ── Loading Text ──────────────────────────────────────────────── */
.loading-text { .loading-text {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
@@ -222,7 +226,9 @@
50% { opacity: 1; } 50% { opacity: 1; }
} }
/* Launch gear (inline, below everything) */ /* ── Launch Gear ────────────────────────────────────────────────── */
/* Appears below the loading text after all services are ready.
Shows a spinning gear + "Launching gear!" text, then redirects to /login. */
.launch-overlay { .launch-overlay {
display: none; display: none;
flex-direction: column; flex-direction: column;
@@ -255,7 +261,9 @@
100% { transform: scale(1); opacity: 1; } 100% { transform: scale(1); opacity: 1; }
} }
/* Error state */ /* ── Error State ────────────────────────────────────────────────── */
/* When a service fails to start within the timeout (60s), show
red text, an error message, and a retry button. */
.loading-text.error { .loading-text.error {
background: linear-gradient(135deg, #ef4444, #f97316); background: linear-gradient(135deg, #ef4444, #f97316);
-webkit-background-clip: text; -webkit-background-clip: text;
@@ -293,11 +301,135 @@
.retry-btn.active { .retry-btn.active {
display: inline-block; display: inline-block;
} }
/* ── Setup Wizard ──────────────────────────────────────────────── */
/* Full-screen 4-step wizard for first-time setup:
1. Environment check (Ollama, model, browser, Facebook login)
2. Browser auto-detection & selection
3. Ollama model pull with progress bar
4. Done — close wizard, start normal loading flow */
.setup-wizard {
display: none;
position: fixed;
inset: 0;
background: #0a0a1a;
z-index: 200;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
overflow-y: auto;
}
.setup-wizard.active { display: flex; }
.setup-step {
display: none;
flex-direction: column;
align-items: center;
max-width: 520px;
width: 100%;
animation: fadeIn 0.3s ease-out;
}
.setup-step.active { display: flex; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.setup-steps {
display: flex;
gap: 8px;
margin-bottom: 32px;
}
.setup-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: rgba(255,255,255,0.15);
transition: all 0.3s;
}
.setup-dot.done { background: #22d3ee; box-shadow: 0 0 6px rgba(34,211,238,0.5); }
.setup-dot.current { background: #6366f1; box-shadow: 0 0 6px rgba(99,102,241,0.5); }
.setup-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
.setup-subtitle { font-size: 14px; opacity: 0.5; margin-bottom: 28px; text-align: center; }
.setup-card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 20px;
width: 100%;
margin-bottom: 20px;
}
.check-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.check-row:last-child { border-bottom: none; }
.check-icon { font-size: 18px; min-width: 24px; text-align: center; }
.check-label { flex: 1; font-size: 14px; }
.check-value { font-size: 12px; opacity: 0.5; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.setup-input {
width: 100%;
padding: 10px 14px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
color: #fff;
font-size: 14px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.setup-input:focus { border-color: #6366f1; }
.setup-btn {
padding: 10px 28px;
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;
}
.setup-btn:hover { opacity: 0.85; }
.setup-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.setup-btn.outline {
background: transparent;
border: 1px solid rgba(255,255,255,0.15);
}
.setup-btn.outline:hover { background: rgba(255,255,255,0.06); }
.setup-btns { display: flex; gap: 12px; margin-top: 8px; }
.progress-bar {
width: 100%;
height: 8px;
background: rgba(255,255,255,0.08);
border-radius: 4px;
overflow: hidden;
margin: 12px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #22d3ee);
border-radius: 4px;
transition: width 0.5s ease;
width: 0%;
}
.status-ok { color: #22d3ee; }
.status-err { color: #ef4444; }
.status-warn { color: #f59e0b; }
</style> </style>
</head> </head>
<body> <body>
<!-- Robot --> <!-- ── Robot Mascot ────────────────────────────────────────────── -->
<div class="robot"> <div class="robot">
<div class="robot-antenna"></div> <div class="robot-antenna"></div>
<div class="robot-head"> <div class="robot-head">
@@ -312,7 +444,7 @@
</div> </div>
</div> </div>
<!-- Services --> <!-- ── Service Status Indicators ───────────────────────────────── -->
<div class="services"> <div class="services">
<div class="service" id="svc-ai"> <div class="service" id="svc-ai">
<span class="service-name">AI Server</span> <span class="service-name">AI Server</span>
@@ -345,109 +477,359 @@
<div class="loading-text" id="loading-text">Loading...</div> <div class="loading-text" id="loading-text">Loading...</div>
<!-- Launch gear (inline, below everything) --> <!-- ── Launch Gear ──────────────────────────────────────────── -->
<div class="launch-overlay" id="launch-overlay"> <div class="launch-overlay" id="launch-overlay">
<div class="launch-gear"></div> <div class="launch-gear"></div>
<div class="launch-text">🚀 Launching gear!</div> <div class="launch-text">Launching gear!</div>
</div> </div>
<div class="error-msg" id="error-msg"></div> <div class="error-msg" id="error-msg"></div>
<button class="retry-btn" id="retry-btn" onclick="location.reload()">Try Again</button> <button class="retry-btn" id="retry-btn" onclick="location.reload()">Try Again</button>
<script> <!-- ══════════════════════════════════════════════════════════════
const MAX_ATTEMPTS = 30; Setup Wizard (4 steps, shown only on first run)
let attempts = 0; ══════════════════════════════════════════════════════════════ -->
<div class="setup-wizard" id="setup-wizard">
<div class="setup-steps">
<div class="setup-dot current" id="sdot-1"></div>
<div class="setup-dot" id="sdot-2"></div>
<div class="setup-dot" id="sdot-3"></div>
<div class="setup-dot" id="sdot-4"></div>
</div>
<!-- Step 1: Environment check -->
<div class="setup-step active" id="sstep-1">
<div class="robot" style="margin-bottom:20px">
<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>
<div class="setup-title">Welcome to CoastIT CRM</div>
<div class="setup-subtitle">Let's check your environment before we start</div>
<div class="setup-card" id="env-checks">
<div class="check-row"><span class="check-icon" id="env-ollama"></span><span class="check-label">Ollama</span><span class="check-value" id="env-ollama-val">checking...</span></div>
<div class="check-row"><span class="check-icon" id="env-model"></span><span class="check-label">AI Model</span><span class="check-value" id="env-model-val">checking...</span></div>
<div class="check-row"><span class="check-icon" id="env-profile"></span><span class="check-label">Browser Profile</span><span class="check-value" id="env-profile-val">checking...</span></div>
<div class="check-row"><span class="check-icon" id="env-fb"></span><span class="check-label">Facebook Login</span><span class="check-value" id="env-fb-val">checking...</span></div>
</div>
<button class="setup-btn" id="welcome-next" onclick="goStep(2)">Next →</button>
</div>
<!-- Step 2: Browser auto-detection & selection -->
<div class="setup-step" id="sstep-2">
<div class="setup-title">Detect your browser</div>
<div class="setup-subtitle">We found these browsers with Facebook login. Click one to select it.</div>
<div class="setup-card" id="browser-list">
<div id="browser-scanning" style="text-align:center;padding:16px;opacity:0.6">🔍 Scanning for browsers...</div>
<div id="browser-results" style="display:none"></div>
</div>
<div class="setup-btns">
<button class="setup-btn outline" onclick="goStep(1)">← Back</button>
<button class="setup-btn" id="browser-confirm" onclick="confirmBrowser()" disabled>Confirm →</button>
</div>
</div>
<!-- Step 3: Ollama Model Pull -->
<div class="setup-step" id="sstep-3">
<div class="setup-title">AI Model</div>
<div class="setup-subtitle">We need to pull the AI model for the scraper to work</div>
<div class="setup-card" style="text-align:center">
<div style="font-size:14px;margin-bottom:8px;font-weight:600" id="model-name">dolphin-llama3:8b</div>
<div class="progress-bar"><div class="progress-fill" id="model-progress-fill"></div></div>
<div style="font-size:12px;opacity:0.5;margin-top:6px" id="model-status">Not downloaded yet</div>
</div>
<div class="setup-btns">
<button class="setup-btn outline" onclick="goStep(3)">← Back</button>
<button class="setup-btn" id="model-pull-btn" onclick="pullModel()">Pull Model</button>
</div>
</div>
<!-- Step 4: Done → Launch -->
<div class="setup-step" id="sstep-4">
<div style="font-size:64px;margin-bottom:16px">🎉</div>
<div class="setup-title">All set!</div>
<div class="setup-subtitle">Your environment is configured. We'll now start all services.</div>
<div class="setup-card" id="final-checks" style="text-align:center">
<div style="font-size:13px;opacity:0.7" id="final-summary">All checks passed</div>
</div>
<button class="setup-btn" onclick="finishSetup()" style="margin-top:8px">Launch 🚀</button>
</div>
</div>
<script>
// ══════════════════════════════════════════════════════════════════
// Client-side JavaScript
// ══════════════════════════════════════════════════════════════════
// ── State ──
let setupData = null;
let currentStep = 1;
const TOTAL_STEPS = 4;
let selectedBrowser = "";
// ── Normal loading state ──
const MAX_ATTEMPTS = 30; // 30 attempts × 2s = 60s timeout
let attempts = 0;
const CHECKS = [ const CHECKS = [
{ id: 'ai', key: 'ai', ready: false, failed: false }, { id: 'ai', key: 'ai', ready: false, failed: false },
{ id: 'scraper', key: 'scraper', ready: false, failed: false }, { id: 'scraper', key: 'scraper', ready: false, failed: false },
{ id: 'frontend',key: 'frontend', ready: false, failed: false }, { id: 'frontend',key: 'frontend', ready: false, failed: false },
]; ];
const MSGS = { ai: 'AI Ready', scraper: 'Scraper Ready', frontend: 'Frontend Ready' }; const MSGS = { ai: 'AI Ready', scraper: 'Scraper Ready', frontend: 'Frontend Ready' };
const NAMES = { ai: 'AI Server', scraper: 'Scraper', frontend: 'Frontend' }; const NAMES = { ai: 'AI Server', scraper: 'Scraper', frontend: 'Frontend' };
const BROWSE_ICONS = { firefox: '🦊', opera: '🔵', chrome: '🌐', edge: '🔷' };
// ── Step navigation ──
function goStep(n) {
currentStep = n;
document.querySelectorAll('.setup-step').forEach((el, i) => {
el.classList.toggle('active', i + 1 === n);
});
document.querySelectorAll('.setup-dot').forEach((el, i) => {
el.classList.toggle('current', i + 1 === n);
el.classList.toggle('done', i + 1 < n);
});
if (n === 2) renderBrowserCards();
}
// ── Step 1: Welcome + Environment check ──
// Fetches /setup/status from the AI server, displays checkmarks/crosses
// for each environment requirement. If first_run is false, skips the
// wizard entirely and goes straight to the normal loading flow.
async function checkEnv() {
try {
const res = await fetch('/setup/status');
setupData = await res.json();
} catch {
setupData = { first_run: true, ollama_running: false, model_available: false, facebook_logged_in: false, browsers: {} };
}
setEnvStatus('ollama', setupData.ollama_running, setupData.ollama_running ? 'Running' : 'Not running');
setEnvStatus('model', setupData.model_available, setupData.model_available ? `${setupData.model_name} ✓` : 'Not pulled');
// Browser summary
const detected = Object.entries(setupData.browsers || {}).filter(([, v]) => v.path)
const loggedIn = detected.filter(([, v]) => v.logged_in)
const browserSummary = loggedIn.length
? loggedIn.map(([b]) => `${b.charAt(0).toUpperCase()+b.slice(1)} ✓`).join(', ')
: detected.length ? 'Found but not logged into Facebook' : 'None detected'
setEnvStatus('profile', detected.length > 0, browserSummary)
setEnvStatus('fb', setupData.facebook_logged_in, setupData.facebook_logged_in ? 'Logged in' : loggedIn.length ? '' : 'Not checked')
if (!setupData.first_run) {
document.getElementById('setup-wizard').classList.remove('active');
startNormalFlow();
return;
}
document.getElementById('setup-wizard').classList.add('active');
}
function setEnvStatus(id, ok, label) {
document.getElementById('env-' + id).textContent = ok ? '✅' : '❌';
document.getElementById('env-' + id + '-val').textContent = label;
}
// ── Step 2: Browser selection ──
// Renders clickable cards for each detected browser.
// Auto-selects the first one that has Facebook login.
// No manual path input needed — all detection is automatic.
function renderBrowserCards() {
const scanning = document.getElementById('browser-scanning');
const results = document.getElementById('browser-results');
const browsers = setupData.browsers || {};
const detected = Object.entries(browsers).filter(([, v]) => v.path)
if (detected.length === 0) {
scanning.textContent = '❌ No browsers with Facebook detected. Log in on your browser and click Retry.';
const retryBtn = document.createElement('button');
retryBtn.className = 'setup-btn';
retryBtn.textContent = 'Retry Detection';
retryBtn.onclick = () => location.reload();
scanning.after(retryBtn);
return;
}
scanning.style.display = 'none';
results.style.display = 'block';
// Auto-select first logged-in browser, or first detected
const firstLoggedIn = detected.find(([, v]) => v.logged_in)
selectedBrowser = firstLoggedIn ? firstLoggedIn[0] : detected[0][0]
results.innerHTML = detected.map(([name, info]) => {
const isSel = name === selectedBrowser
const icon = BROWSE_ICONS[name] || '🌐'
const status = info.logged_in ? '✅ Logged in' : info.path ? '⚠️ Not logged in' : '❌ Not found'
const pathShort = info.path ? info.path.length > 45 ? '...' + info.path.slice(-42) : info.path : ''
return `<div class="browser-card ${isSel ? 'selected' : ''}" data-browser="${name}" onclick="pickBrowser('${name}')" style="display:flex;align-items:center;gap:12px;padding:12px;border:2px solid ${isSel ? '#6366f1' : 'rgba(255,255,255,0.08)'};border-radius:10px;margin-bottom:8px;cursor:pointer;transition:all 0.2s">
<span style="font-size:24px">${icon}</span>
<div style="flex:1"><strong style="text-transform:capitalize">${name}</strong><div style="font-size:12px;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${pathShort}</div></div>
<span style="font-size:13px">${status}</span>
</div>`
}).join('')
document.getElementById('browser-confirm').disabled = false
}
function pickBrowser(name) {
selectedBrowser = name
document.querySelectorAll('.browser-card').forEach(el => {
el.style.borderColor = el.dataset.browser === name ? '#6366f1' : 'rgba(255,255,255,0.08)'
})
}
// Saves the selected browser to .env.local via the AI server's /setup/profile endpoint
async function confirmBrowser() {
if (!selectedBrowser) return
const btn = document.getElementById('browser-confirm')
btn.textContent = 'Saving...'
btn.disabled = true
const path = setupData.browsers[selectedBrowser].path
try {
const res = await fetch('/setup/profile', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ browser: selectedBrowser, path })
})
const data = await res.json()
if (data.success) {
setupData.selected_browser = selectedBrowser
goStep(3)
} else {
alert('Failed to save: ' + (data.error || 'unknown'))
btn.disabled = false
}
} catch {
alert('Could not save profile')
btn.disabled = false
}
btn.textContent = 'Confirm →'
}
// ── Step 3: Ollama Model Pull ──
// Kicks off an "ollama pull" via the AI server and polls for progress.
let pullPolling = false;
async function pullModel() {
if (pullPolling) return;
const btn = document.getElementById('model-pull-btn');
const fill = document.getElementById('model-progress-fill');
const status = document.getElementById('model-status');
btn.disabled = true;
btn.textContent = 'Starting...';
try {
const res = await fetch('/setup/ollama/pull', { method: 'POST' });
const data = await res.json();
if (data.status === 'already_running') { btn.textContent = 'Already running'; return }
btn.textContent = 'Downloading...';
pullPolling = true;
pollPullProgress();
} catch {
status.textContent = 'Failed to start download';
status.className = 'status-err';
btn.disabled = false;
btn.textContent = 'Retry';
}
}
async function pollPullProgress() {
const fill = document.getElementById('model-progress-fill');
const status = document.getElementById('model-status');
const btn = document.getElementById('model-pull-btn');
try {
const res = await fetch('/setup/ollama/pull/progress');
const data = await res.json();
fill.style.width = data.progress + '%';
if (data.status === 'done') {
status.textContent = '✅ Model downloaded!';
status.className = 'status-ok';
btn.textContent = 'Done';
setupData.model_available = true;
pullPolling = false;
setTimeout(() => goStep(4), 1500);
return;
}
if (data.status === 'failed') {
status.textContent = '❌ Download failed: ' + data.message;
status.className = 'status-err';
btn.textContent = 'Retry';
btn.disabled = false;
pullPolling = false;
return;
}
status.textContent = data.message || `Downloading... ${data.progress}%`;
setTimeout(pollPullProgress, 1000);
} catch {
status.textContent = 'Checking...';
setTimeout(pollPullProgress, 2000);
}
}
// ── Step 4: Finish Setup ──
// Closes the wizard and starts the normal loading flow.
function finishSetup() {
document.getElementById('setup-wizard').classList.remove('active');
startNormalFlow();
}
// ── Normal Loading Flow ──
// Polls /status every 2 seconds for up to 60 seconds.
// Tracks each service's state (ready/waiting/failed).
// All ready → show launch gear, redirect to /login after 5s.
// Any failed → show error message + retry button.
function startNormalFlow() { poll() }
async function checkAllServices() { async function checkAllServices() {
try { try {
const res = await fetch('/status', { cache: 'no-store' }); const res = await fetch('/status', { cache: 'no-store' });
const data = await res.json(); const data = await res.json();
for (const svc of CHECKS) { for (const svc of CHECKS) svc.ready = data[svc.key] === true;
svc.ready = data[svc.key] === true; } catch { for (const svc of CHECKS) svc.ready = false }
}
} catch {
for (const svc of CHECKS) svc.ready = false;
}
} }
function updateUI() { function updateUI() {
let allReady = true; let allReady = true, anyFailed = false;
let anyFailed = false;
const failedNames = []; const failedNames = [];
for (const svc of CHECKS) { for (const svc of CHECKS) {
const el = document.getElementById('svc-' + svc.id); const el = document.getElementById('svc-' + svc.id);
const statusText = document.getElementById('status-' + svc.id); const st = document.getElementById('status-' + svc.id);
el.classList.remove('ready', 'failed'); el.classList.remove('ready', 'failed');
if (svc.ready) { el.classList.add('ready'); st.textContent = MSGS[svc.id] }
if (svc.ready) { else if (svc.failed) { el.classList.add('failed'); st.textContent = 'Failed'; anyFailed = true; allReady = false; failedNames.push(NAMES[svc.id]) }
el.classList.add('ready'); else { st.textContent = 'waiting...'; allReady = false }
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 lt = document.getElementById('loading-text'), em = document.getElementById('error-msg'), rb = document.getElementById('retry-btn'), lo = document.getElementById('launch-overlay');
const loadingText = document.getElementById('loading-text');
const errorMsg = document.getElementById('error-msg');
const retryBtn = document.getElementById('retry-btn');
if (anyFailed) { if (anyFailed) {
loadingText.className = 'loading-text error'; lt.className = 'loading-text error'; lt.textContent = 'Something went wrong';
loadingText.textContent = 'Something went wrong'; em.textContent = failedNames.join(', ') + ' failed to start. Check your terminal.';
errorMsg.textContent = failedNames.join(', ') + ' failed to start. Check your terminal for details.'; em.classList.add('active'); rb.classList.add('active'); lo.classList.remove('active');
errorMsg.classList.add('active');
retryBtn.classList.add('active');
document.getElementById('launch-overlay').classList.remove('active');
} else if (allReady) { } else if (allReady) {
loadingText.className = 'loading-text'; lt.className = 'loading-text'; lt.textContent = 'All systems ready!';
loadingText.textContent = 'All systems ready!'; em.classList.remove('active'); rb.classList.remove('active');
errorMsg.classList.remove('active'); lo.classList.add('active');
retryBtn.classList.remove('active');
document.getElementById('launch-overlay').classList.add('active');
setTimeout(() => { window.location.href = 'http://localhost:3006/login'; }, 5000); setTimeout(() => { window.location.href = 'http://localhost:3006/login'; }, 5000);
} else { } else {
loadingText.className = 'loading-text'; lt.className = 'loading-text';
const ready = CHECKS.filter(s => s.ready).length; lt.textContent = `Loading... (${CHECKS.filter(s => s.ready).length}/${CHECKS.length})`;
loadingText.textContent = `Loading... (${ready}/${CHECKS.length})`;
} }
} }
async function poll() { async function poll() {
attempts++; attempts++;
if (attempts > MAX_ATTEMPTS) { if (attempts > MAX_ATTEMPTS) {
for (const svc of CHECKS) { for (const svc of CHECKS) { if (!svc.ready) svc.failed = true }
if (!svc.ready) svc.failed = true; updateUI(); return;
} }
updateUI(); await checkAllServices(); updateUI();
return; if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed))
}
await checkAllServices();
updateUI();
if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed)) {
setTimeout(poll, 2000); setTimeout(poll, 2000);
} }
}
poll(); // ── Boot ──
// Entry point: check environment → shows wizard or starts normal flow
checkEnv();
</script> </script>
</body> </body>
</html> </html>
+126 -55
View File
@@ -1,85 +1,156 @@
// ───────────────────────────────────────────────
// AI Assistant Page (/(dashboard)/ai-assistant)
// ───────────────────────────────────────────────
// Route: /ai-assistant
// Purpose: AI-powered sales assistant with
// conversational chat and Facebook lead
// scraping by job title. Users select a target
// job, trigger a Facebook scrape, and review
// results in the chat panel.
// Layout: Two-column — main chat area + right
// sidebar with job selector / details.
// ───────────────────────────────────────────────
"use client" "use client"
import { useState, useCallback } from "react" import { useState, useCallback, useRef } from "react"
import { AIChat } from "@/components/ai/ai-chat" import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat"
import { JobSelector } from "@/components/ai/job-selector" import { JobSelector } from "@/components/ai/job-selector"
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react" import { Bot, ChevronRight } from "lucide-react"
/**
* AIAssistantPage
*
* Manages the AI chat + job search workflow.
* Selected job is shown in the sidebar; search
* triggers a Facebook scrape via an external
* scraper service with a 6-minute timeout.
*
* @returns AI assistant layout.
*/
export default function AIAssistantPage() { export default function AIAssistantPage() {
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null) const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
const [searching, setSearching] = useState(false)
const aiChatRef = useRef<AIChatHandle | null>(null)
const handleJobSelect = useCallback((job: typeof selectedJob) => { const handleJobSelect = useCallback((job: typeof selectedJob) => {
setSelectedJob(job) setSelectedJob(job)
}, []) }, [])
// ── Facebook lead scraper handler ──
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
setSearching(true)
const keyword = job.keywords?.[0] || job.job_title
aiChatRef.current?.addAssistantMessage(`🔍 Searching Facebook for **${job.job_title}** leads...`)
// 6-minute hard abort, with a "still searching" status message at 45 s
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 360000)
const statusId = setTimeout(() => {
aiChatRef.current?.addAssistantMessage("⏳ Still searching Facebook (this can take up to 5 minutes)...")
}, 45000)
const scrapBase = process.env.NEXT_PUBLIC_SCRAPER_URL || "http://localhost:3008"
try {
const res = await fetch(`${scrapBase}/scrape/facebook?force=true&query=${encodeURIComponent(keyword)}`, { method: "POST", signal: controller.signal })
clearTimeout(timeoutId)
clearTimeout(statusId)
const data = await res.json()
if (data.success && data.leads?.length > 0) {
// Format scraped leads as a markdown list for the chat
const leadLines = data.leads
.filter(Boolean)
.map((lead: Record<string, string>, i: number) =>
`**${i + 1}.** ${lead?.author || "Unknown"}\n> ${(lead?.content || "").slice(0, 300)}\n> 🔗 ${lead?.url || "(no link available)"}`
)
const leadsText = leadLines.join("\n\n")
aiChatRef.current?.addAssistantMessage(`✅ Found **${data.leads.length}** leads:\n\n${leadsText}`)
} else {
const reason = data.error || data.flag_reason || "No leads found this time"
aiChatRef.current?.addAssistantMessage(`⚠️ ${reason}`)
}
} catch (err: any) {
clearTimeout(timeoutId)
clearTimeout(statusId)
const msg = err.name === "AbortError"
? "❌ Scraper timed out after 5 minutes. Try again or check the scraper service."
: `${err instanceof Error ? err.message : "Search failed"}`
aiChatRef.current?.addAssistantMessage(msg)
} finally {
setSearching(false)
}
}, [])
const handleRecentPromptClick = useCallback((prompt: string) => {
aiChatRef.current?.fillInput(prompt)
}, [])
// Keep track of last 3 sent prompts for quick reuse
const handleMessageSent = useCallback((msg: string) => {
setRecentPrompts((prev) => {
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
return next
})
}, [])
return ( return (
<div className="flex h-[calc(100vh-3.5rem)]"> <div className="flex h-[calc(100vh-3.5rem)] bg-background">
<div className="flex-1 flex flex-col min-w-0"> {/* ── Main chat panel ── */}
<div className="border-b border-[#2a2a35] px-6 py-4"> <div className="flex-1 flex flex-col min-w-0 border border-foreground transition-colors overflow-hidden">
<div className="flex items-center gap-3"> {/* Header */}
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center"> <div className="bg-card/80 backdrop-blur-md border-b border-border px-6 py-4">
<Bot className="h-5 w-5 text-[#1BB0CE]" /> <div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary flex items-center justify-center" style={{boxShadow: "0 0 40px hsl(var(--primary) / 0.3)"}}>
<Bot className="h-5 w-5 text-white" />
</div> </div>
<div> <div>
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1> <h1 className="text-foreground font-bold text-lg">AI Sales Assistant</h1>
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p> <p className="text-muted-foreground text-xs mt-0.5">Powered by local AI</p>
</div> </div>
</div> </div>
{/* Status indicator */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
<span className="text-[#22c55e] text-xs font-medium">Online</span>
</div>
<div className="w-px h-5 bg-border" />
<div className="bg-muted/50 rounded-lg px-3 py-1.5">
<span className="text-muted-foreground text-xs">Local AI Model</span>
</div>
</div>
</div>
</div>
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
</div> </div>
<div className="flex-1 flex"> {/* ── Right sidebar: Job selector ── */}
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]"> <div className="w-[300px] flex-none bg-sidebar border border-foreground transition-colors p-5 overflow-y-auto h-full flex flex-col">
<AIChat />
</div>
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
<div> <div>
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5"> <div className="flex items-center gap-2 mb-3">
<Target className="h-3.5 w-3.5" /> <span className="w-1.5 h-1.5 rounded-full bg-primary" />
Target Job <span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
</h3>
<JobSelector onSelect={handleJobSelect} />
</div> </div>
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
{/* Selected job details card */}
{selectedJob && ( {selectedJob && (
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2"> <div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2">
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4> <h4 className="text-sm font-semibold text-foreground">{selectedJob.job_title}</h4>
<div className="flex items-center gap-1.5"> <span className="text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary font-medium inline-block">{selectedJob.industry}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span> <p className="text-xs text-muted-foreground leading-relaxed">{selectedJob.description}</p>
</div> <div className="flex flex-wrap gap-1.5">
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
<div className="flex flex-wrap gap-1">
{selectedJob.keywords.map((kw, i) => ( {selectedJob.keywords.map((kw, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]"> <span key={i} className="text-xs px-2 py-0.5 rounded-md bg-muted/50 text-muted-foreground">{kw}</span>
{kw}
</span>
))} ))}
</div> </div>
</div> </div>
)} )}
</div>
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5">
<Lightbulb className="h-3.5 w-3.5" />
Tips
</h4>
<ul className="space-y-1.5 text-xs text-[#8a8a95]">
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Ask for cold email templates for a specific job
</li>
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Request objection handling tips
</li>
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Ask for outreach strategies per industry
</li>
</ul>
</div>
</div>
</div>
</div> </div>
</div> </div>
) )
File diff suppressed because it is too large Load Diff
+570 -72
View File
@@ -1,3 +1,17 @@
// ───────────────────────────────────────────────
// Chats Page (/(dashboard)/chats)
// ───────────────────────────────────────────────
// Route: /chats
// Purpose: Full-featured real-time messaging with
// voice notes, file attachments, GIFs, stickers,
// message editing/deletion/forwarding, read
// receipts, emoji picker, inline image/avatar
// previews, voice calls, and event scheduling.
// Layout: Resizable two-panel layout — left:
// conversation list; right: chat area (header,
// messages, input bar).
// ───────────────────────────────────────────────
"use client" "use client"
import { useState, useRef, useCallback, useEffect, useMemo } from "react" import { useState, useRef, useCallback, useEffect, useMemo } from "react"
@@ -17,49 +31,67 @@ import {
import { import {
Search, Send, Phone, 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, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
} from "lucide-react" } from "lucide-react"
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
import { sanitizeSvg } from "@/lib/sanitize"
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
DialogFooter, DialogClose, DialogFooter, DialogClose,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"
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 { io, Socket } from "socket.io-client"
import VoiceCallModal from "@/components/chats/voice-call-modal" import VoiceCallModal from "@/components/chats/voice-call-modal"
import data from "@emoji-mart/data" import MediaPicker from "@/components/chats/media-picker"
import Picker from "@emoji-mart/react"
function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDuration: number }) { /** Module-level refs to prevent multiple audio instances from playing simultaneously. */
const activeAudioRef: { current: HTMLAudioElement | null } = { current: null }
const previewAudioRef: { current: HTMLAudioElement | null } = { current: null }
const previewAnimRef: { current: number } = { current: 0 }
// ──────────────────────────────────────────────
// VoiceMessagePlayer
// ──────────────────────────────────────────────
/**
* Renders a voice note message with:
* - Play/pause toggle
* - Animated waveform bars
* - Seekable progress slider
* - Elapsed / total duration
* - Replay button
*
* Only one audio plays globally via activeAudioRef.
*/
function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; initialDuration: number; isOwn?: boolean }) {
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
const [current, setCurrent] = useState(0) const [current, setCurrent] = useState(0)
const [duration, setDuration] = useState(initialDuration) const [duration, setDuration] = useState(initialDuration)
const audioRef = useRef<HTMLAudioElement>(null) const audioRef = useRef<HTMLAudioElement>(null)
const animRef = useRef<number>(0) const animRef = useRef<number>(0)
const progRef = useRef<HTMLInputElement>(null)
// Load actual duration from metadata (fallback to initialDuration)
useEffect(() => { useEffect(() => {
const audio = audioRef.current const audio = audioRef.current
if (!audio) return if (!audio) return
const onLoaded = () => { const onLoaded = () => { if (isFinite(audio.duration)) setDuration(audio.duration) }
if (isFinite(audio.duration)) setDuration(audio.duration)
}
const onEnded = () => { setPlaying(false); setCurrent(0) } const onEnded = () => { setPlaying(false); setCurrent(0) }
audio.addEventListener("loadedmetadata", onLoaded) audio.addEventListener("loadedmetadata", onLoaded)
audio.addEventListener("ended", onEnded) audio.addEventListener("ended", onEnded)
return () => { return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("ended", onEnded) }
audio.removeEventListener("loadedmetadata", onLoaded)
audio.removeEventListener("ended", onEnded)
}
}, [src]) }, [src])
// Animation frame loop — updates current time while playing
useEffect(() => { useEffect(() => {
if (!playing) { cancelAnimationFrame(animRef.current); return } if (!playing) { cancelAnimationFrame(animRef.current); return }
const tick = () => { const tick = () => { if (audioRef.current) setCurrent(audioRef.current.currentTime); animRef.current = requestAnimationFrame(tick) }
if (audioRef.current) setCurrent(audioRef.current.currentTime)
animRef.current = requestAnimationFrame(tick)
}
animRef.current = requestAnimationFrame(tick) animRef.current = requestAnimationFrame(tick)
return () => cancelAnimationFrame(animRef.current) return () => cancelAnimationFrame(animRef.current)
}, [playing]) }, [playing])
@@ -67,39 +99,86 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura
const toggle = () => { const toggle = () => {
if (!audioRef.current) return if (!audioRef.current) return
if (playing) { audioRef.current.pause(); setPlaying(false) } if (playing) { audioRef.current.pause(); setPlaying(false) }
else { audioRef.current.play(); setPlaying(true) } else {
// Pause any other playing voice note
if (activeAudioRef.current && activeAudioRef.current !== audioRef.current) { activeAudioRef.current.pause() }
activeAudioRef.current = audioRef.current
audioRef.current.play(); setPlaying(true)
}
}
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value)
if (audioRef.current) { audioRef.current.currentTime = val; setCurrent(val) }
}
const replay = () => {
if (!audioRef.current) return
audioRef.current.currentTime = 0; setCurrent(0)
if (!playing) { audioRef.current.play(); setPlaying(true) }
} }
const displayDuration = isFinite(duration) && duration > 0 ? duration : initialDuration const displayDuration = isFinite(duration) && duration > 0 ? duration : initialDuration
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0 const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
const fmt = (s: number) => { const fmt = (s: number) => { const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0; return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}` }
const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0
return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}` // Generate 28 pseudo-random waveform bar heights using sine combinations
} const barCount = 28
const waveform = Array.from({ length: barCount }, (_, i) => {
const peak = 0.15 + Math.sin(i * 1.1) * 0.35 + Math.sin(i * 2.3) * 0.2 + Math.sin(i * 0.7) * 0.3
return Math.max(0.15, Math.min(1, peak))
})
return ( return (
<div className="flex items-center gap-2 min-w-[180px]"> <div className="flex items-center gap-2 min-w-[200px] select-none">
<audio ref={audioRef} src={src} preload="metadata" /> <audio ref={audioRef} src={src} preload="metadata" />
<button type="button" onClick={toggle} className="h-8 w-8 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors"> <button type="button" onClick={toggle} className="h-8 w-8 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
{playing ? <Pause className="h-4 w-4 fill-current" /> : <Play className="h-4 w-4 ml-0.5 fill-current" />} {playing ? <Pause className="h-4 w-4 fill-current" /> : <Play className="h-4 w-4 ml-0.5 fill-current" />}
</button> </button>
<div className="flex-1 h-1.5 rounded-full bg-white/30 relative overflow-hidden"> <div className="flex-1 flex flex-col gap-0.5 min-w-0">
<div className="h-full rounded-full bg-white transition-all duration-100" style={{ width: `${pct}%` }} /> <div className="flex items-center gap-1 h-8">
{waveform.map((h, i) => {
const filled = (i / barCount) <= (pct / 100)
return <div key={i} className="flex-1 rounded-full transition-colors duration-75" style={{ height: `${h * 100}%`, minHeight: 3, background: filled ? "currentColor" : "currentColor", opacity: filled ? 1 : 0.35 }} />
})}
</div>
<input type="range" ref={progRef} min={0} max={displayDuration || 1} step={0.1} value={Math.min(current, displayDuration)} onChange={seek} className="w-full h-1 cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-white/20 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-0" />
</div>
<div className="flex flex-col items-end gap-0.5 shrink-0">
<span className="text-[11px] tabular-nums opacity-70">{fmt(current)}</span>
<button type="button" onClick={replay} className="opacity-50 hover:opacity-100 transition-opacity">
<Undo2 className="h-2.5 w-2.5" />
</button>
</div> </div>
<span className="text-[11px] tabular-nums opacity-70 w-8 text-right">{fmt(displayDuration)}</span>
</div> </div>
) )
} }
/** Max conversations to keep in client-side cache (LRU). */
const MAX_CACHED_CONVERSATIONS = 5 const MAX_CACHED_CONVERSATIONS = 5
const otherParticipant = (conv: any) => conv.otherUser const otherParticipant = (conv: any) => conv.otherUser
// ──────────────────────────────────────────────
// ChatsPage — Main Chat Application Component
// ──────────────────────────────────────────────
/**
* Full-featured chat interface. Manages:
* - Conversation list with search / create
* - Real-time message polling (4 s) + Socket.io
* - Voice recording, file attachments, GIF/stickers
* - Message edit, delete, forward, reply
* - Resizable panel, read receipts, emoji picker
* - Voice call modal + event scheduling
*
* @returns Chat layout or null if no user.
*/
export default function ChatsPage() { export default function ChatsPage() {
const { theme } = useTheme() const { theme } = useTheme()
const { user } = useUser() const { user } = useUser()
const [activeChat, setActiveChat] = useState<string | null>(null) const [activeChat, setActiveChat] = useState<string | null>(null)
const [messageInput, setMessageInput] = useState("") const [messageInput, setMessageInput] = useState("")
const [showEmojiPicker, setShowEmojiPicker] = useState(false) const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [showAttachMenu, setShowAttachMenu] = useState(false)
const [attachments, setAttachments] = useState<File[]>([]) const [attachments, setAttachments] = useState<File[]>([])
const [panelWidth, setPanelWidth] = useState(320) const [panelWidth, setPanelWidth] = useState(320)
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
@@ -113,6 +192,11 @@ export default function ChatsPage() {
const [isPaused, setIsPaused] = useState(false) const [isPaused, setIsPaused] = useState(false)
const [recordingDuration, setRecordingDuration] = useState(0) const [recordingDuration, setRecordingDuration] = useState(0)
const recordingDurationRef = useRef(0) const recordingDurationRef = useRef(0)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewDuration, setPreviewDuration] = useState(0)
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false)
const [previewCurrent, setPreviewCurrent] = useState(0)
const [voiceMessages, setVoiceMessages] = useState<Map<string, { url: string; duration: number }>>(new Map()) const [voiceMessages, setVoiceMessages] = useState<Map<string, { url: string; duration: number }>>(new Map())
const [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map()) const [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map())
const [replyingTo, setReplyingTo] = useState<any>(null) const [replyingTo, setReplyingTo] = useState<any>(null)
@@ -125,20 +209,47 @@ 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 [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null)
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false)
const [scheduleTitle, setScheduleTitle] = useState("")
const [scheduleDate, setScheduleDate] = useState("")
const [scheduleTime, setScheduleTime] = useState("")
const [scheduleDuration, setScheduleDuration] = useState("30")
const [scheduleType, setScheduleType] = useState<string>("meeting")
const [scheduleSaving, setScheduleSaving] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const emojiPickerRef = useRef<HTMLDivElement>(null) const emojiPickerRef = useRef<HTMLDivElement>(null)
const isSendingRef = useRef(false)
const attachMenuRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null) const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const recordingChunksRef = useRef<Blob[]>([]) const recordingChunksRef = useRef<Blob[]>([])
const resizeStartRef = useRef({ x: 0, width: 0 }) const resizeStartRef = useRef({ x: 0, width: 0 })
const blobUrlsRef = useRef<string[]>([]) const blobUrlsRef = useRef<string[]>([])
const conversationOrderRef = useRef<string[]>([]) const conversationOrderRef = useRef<string[]>([])
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
const socketRef = useRef<Socket | null>(null)
const isDeletingRef = useRef(false)
/** Check if a string consists only of emoji characters. */
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim()) const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
/** Parse message content for preview display in conversation list. */
const formatPreviewContent = (content: string) => {
if (!content?.startsWith("{")) return content
try {
const p = JSON.parse(content)
if (p.gif) return "📷 GIF"
if (p.v) return "🎤 Voice message"
if (p.sticker) return "💮 Sticker"
} catch {}
return content
}
/** Auto-resize the textarea based on its scroll height. */
const autoResizeTextarea = useCallback(() => { const autoResizeTextarea = useCallback(() => {
const ta = textareaRef.current const ta = textareaRef.current
if (!ta) return if (!ta) return
@@ -148,6 +259,7 @@ export default function ChatsPage() {
if (!user) return null if (!user) return null
// ── Memoized derived data ──
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat]) const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat]) const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
const filteredConversations = useMemo(() => { const filteredConversations = useMemo(() => {
@@ -208,6 +320,17 @@ export default function ChatsPage() {
setMessageAttachments((a) => { setMessageAttachments((a) => {
const n = new Map(a) const n = new Map(a)
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k) for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
// Restore persisted file attachments from JSON content
for (const msgs of next.values()) {
for (const m of msgs) {
try {
const parsed = JSON.parse(m.content)
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
n.set(m.id, parsed.fileAttachments)
}
} catch {}
}
}
return n return n
}) })
setReplyMap((r) => { setReplyMap((r) => {
@@ -257,6 +380,26 @@ export default function ChatsPage() {
return () => document.removeEventListener("mousedown", handleClickOutside) return () => document.removeEventListener("mousedown", handleClickOutside)
}, []) }, [])
// Strip blocked files from attachments on every change
useEffect(() => {
if (attachments.length === 0) return
const { allowed, rejected } = filterBlockedFiles(attachments)
if (rejected.length > 0) {
toast.error(`Blocked file type: ${rejected[0].name}`)
setAttachments(allowed)
}
}, [attachments])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (attachMenuRef.current && !attachMenuRef.current.contains(e.target as Node)) {
setShowAttachMenu(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
// revoke blob URLs on unmount // revoke blob URLs on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -265,6 +408,65 @@ export default function ChatsPage() {
} }
}, []) }, [])
// Poll for new messages every 4 seconds for real-time sync
useEffect(() => {
if (!activeChat) return
pollTimerRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/conversations/${activeChat}/messages?limit=50`)
if (!res.ok) return
const data = await res.json()
const serverMsgs = data.messages || []
setConversationMessages((prev) => {
const existing = prev.get(activeChat) || []
if (serverMsgs.length === existing.length) return prev
const next = new Map(prev)
next.set(activeChat, serverMsgs)
return next
})
setConversations((prev) => {
const updated = [...prev]
const convIdx = updated.findIndex((c) => c.id === activeChat)
if (convIdx >= 0 && serverMsgs.length > 0) {
const last = serverMsgs[serverMsgs.length - 1]
updated[convIdx] = { ...updated[convIdx], lastMessage: last.content, lastMessageTime: last.timestamp }
}
return updated
})
} catch { /* polling silently */ }
}, 4000)
return () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current) }
}, [activeChat])
// Connect to signaling server for real-time message deletion events
useEffect(() => {
let socket: Socket | null = null
let cancelled = false
;(async () => {
try {
const res = await fetch("/api/auth/jwt")
if (!res.ok) return
const { token } = await res.json()
if (cancelled) return
socket = io(process.env.NEXT_PUBLIC_SIGNALING_URL || "http://localhost:3007", {
auth: { token },
transports: ["websocket", "polling"],
})
socketRef.current = socket
socket.on("message:deleted", ({ conversationId, messageId }: { conversationId: string; messageId: string }) => {
setConversationMessages((prev) => {
const existing = prev.get(conversationId)
if (!existing) return prev
const next = new Map(prev)
next.set(conversationId, existing.filter((m) => m.id !== messageId))
return next
})
})
} catch { /* socket connection failed silently */ }
})()
return () => { cancelled = true; socket?.disconnect(); socketRef.current = null }
}, [])
const handleResizeStart = useCallback((e: React.MouseEvent) => { const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
setIsResizing(true) setIsResizing(true)
@@ -291,23 +493,32 @@ export default function ChatsPage() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [conversations]) }, [conversations])
const handleEmojiSelect = (emoji: { native: string }) => { const handleEmojiSelect = (emoji: string) => {
setMessageInput((prev) => prev + emoji.native) setMessageInput((prev) => prev + emoji)
setShowEmojiPicker(false) setShowEmojiPicker(false)
setTimeout(() => autoResizeTextarea(), 0) setTimeout(() => autoResizeTextarea(), 0)
} }
const handleMediaSelect = (content: string) => {
addMessageToChat(content)
setShowEmojiPicker(false)
}
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []) const raw = Array.from(e.target.files ?? [])
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE) const oversized = raw.filter((f) => f.size > MAX_FILE_SIZE)
if (oversized.length > 0) { if (oversized.length > 0) {
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`) toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
if (e.target) e.target.value = "" if (e.target) e.target.value = ""
return return
} }
setAttachments((prev) => [...prev, ...files]) const { allowed, rejected } = filterBlockedFiles(raw)
if (rejected.length > 0) {
toast.error(`Blocked file type: ${rejected[0].name}`)
}
setAttachments((prev) => [...prev, ...allowed])
if (e.target) e.target.value = "" if (e.target) e.target.value = ""
} }
@@ -317,7 +528,11 @@ export default function ChatsPage() {
const addMessageToChat = async (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, fileAttachments?: { name: string; type: string; url: string }[]) => { const addMessageToChat = async (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, fileAttachments?: { name: string; type: string; url: string }[]) => {
if (!activeChat || !user) return if (!activeChat || !user) return
const payload = voice ? "[Voice message]" : content const payload = voice
? "[Voice message]"
: fileAttachments && fileAttachments.length > 0
? JSON.stringify({ text: content, fileAttachments })
: content
try { try {
const res = await fetch(`/api/conversations/${activeChat}/messages`, { const res = await fetch(`/api/conversations/${activeChat}/messages`, {
method: "POST", method: "POST",
@@ -336,7 +551,7 @@ export default function ChatsPage() {
setConversations((prev) => { setConversations((prev) => {
const updated = prev.map((conv) => const updated = prev.map((conv) =>
conv.id === activeChat conv.id === activeChat
? { ...conv, lastMessage: content, lastMessageTime: "Just now" } ? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
: conv : conv
) )
const idx = updated.findIndex((c) => c.id === activeChat) const idx = updated.findIndex((c) => c.id === activeChat)
@@ -378,6 +593,14 @@ export default function ChatsPage() {
const handleSend = async (e: React.FormEvent) => { const handleSend = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!messageInput.trim() && attachments.length === 0) return if (!messageInput.trim() && attachments.length === 0) return
if (isSendingRef.current) return
// Check blocked files before sending
if (attachments.some((f) => hasBlockedCodeExtension(f.name))) {
toast.error("Remove blocked file types before sending")
return
}
isSendingRef.current = true
try {
if (editingMessage) { if (editingMessage) {
setConversationMessages((prev) => { setConversationMessages((prev) => {
const next = new Map(prev) const next = new Map(prev)
@@ -393,11 +616,10 @@ export default function ChatsPage() {
return return
} }
const fileAttachments = attachments.length > 0 const fileAttachments = attachments.length > 0
? attachments.map((f) => { ? await Promise.all(attachments.map(async (f) => {
const url = URL.createObjectURL(f) const url = await fileToDataURL(f)
blobUrlsRef.current.push(url)
return { name: f.name, type: f.type, url } return { name: f.name, type: f.type, url }
}) }))
: undefined : undefined
if (replyingTo) { if (replyingTo) {
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
@@ -409,13 +631,38 @@ export default function ChatsPage() {
setMessageInput("") setMessageInput("")
setTimeout(() => autoResizeTextarea(), 0) setTimeout(() => autoResizeTextarea(), 0)
setAttachments([]) setAttachments([])
} finally {
isSendingRef.current = false
}
} }
const handleDeleteMessage = (messageId: string) => { const handleDeleteMessage = async (messageId: string) => {
if (!activeChat || !user) return
if (isDeletingRef.current) return
isDeletingRef.current = true
const prevMessages = conversationMessages.get(activeChat) || []
try {
const res = await fetch(`/api/conversations/${activeChat}/messages/${messageId}`, { method: "DELETE" })
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || "Delete failed")
}
// Broadcast deletion to other participants via Socket.io
const socket = socketRef.current
if (socket?.connected) {
socket.emit("message:deleted", { conversationId: activeChat, messageId, senderId: user.id })
}
// Revoke blob URLs for attachments and voice
const files = messageAttachments.get(messageId) const files = messageAttachments.get(messageId)
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) }) if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
const voice = voiceMessages.get(messageId) const voice = voiceMessages.get(messageId)
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) } if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
// Remove from local state
setConversationMessages((prev) => { setConversationMessages((prev) => {
const next = new Map(prev) const next = new Map(prev)
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId) const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
@@ -425,26 +672,35 @@ export default function ChatsPage() {
setConversations((prev) => setConversations((prev) =>
prev.map((conv) => prev.map((conv) =>
conv.id === activeChat conv.id === activeChat
? { ...conv, lastMessage: conversationMessages.get(activeChat || "")?.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage } ? { ...conv, lastMessage: getDisplayContent(prevMessages.filter((m) => m.id !== messageId).at(-1)?.content ?? "") }
: conv : conv
) )
) )
toast.success("Message deleted") toast.success("Message deleted")
} catch (err: any) {
toast.error(err?.message || "Unable to delete voice note. Please try again.")
} finally {
isDeletingRef.current = false
}
} }
const startRecording = async () => { const startRecording = async () => {
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" }) const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/webm"
const recorder = new MediaRecorder(stream, { mimeType: mime })
recordingChunksRef.current = [] recordingChunksRef.current = []
recorder.ondataavailable = (e) => { recorder.ondataavailable = (e) => {
if (e.data.size > 0) recordingChunksRef.current.push(e.data) if (e.data.size > 0) recordingChunksRef.current.push(e.data)
} }
recorder.onstop = () => { recorder.onstop = () => {
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" }) if (recordingChunksRef.current.length === 0) return
const blob = new Blob(recordingChunksRef.current, { type: mime })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
blobUrlsRef.current.push(url) blobUrlsRef.current.push(url)
addMessageToChat("", { url, duration: recordingDurationRef.current }) setPreviewBlob(blob)
setPreviewUrl(url)
setPreviewDuration(recordingDurationRef.current)
stream.getTracks().forEach((t) => t.stop()) stream.getTracks().forEach((t) => t.stop())
setRecordingDuration(0) setRecordingDuration(0)
} }
@@ -452,9 +708,12 @@ export default function ChatsPage() {
recorder.start() recorder.start()
setIsRecording(true) setIsRecording(true)
setIsPaused(false) setIsPaused(false)
} catch { } catch (err: any) {
console.warn("Failed to start recording in chats page") if (err?.name === "NotAllowedError" || err?.name === "PermissionDeniedError") {
toast.error("Microphone access denied") toast.error("Microphone access is required to record voice notes.")
} else {
toast.error("Failed to start recording. Please check your microphone.")
}
} }
} }
@@ -488,6 +747,60 @@ export default function ChatsPage() {
setRecordingDuration(0) setRecordingDuration(0)
recordingDurationRef.current = 0 recordingDurationRef.current = 0
recordingChunksRef.current = [] recordingChunksRef.current = []
clearPreview()
}
const clearPreview = () => {
if (previewUrl) { URL.revokeObjectURL(previewUrl); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== previewUrl) }
setPreviewBlob(null)
setPreviewUrl(null)
setPreviewDuration(0)
setIsPreviewPlaying(false)
setPreviewCurrent(0)
}
const cancelVoicePreview = () => {
if (previewAudioRef.current) { previewAudioRef.current.pause(); previewAudioRef.current = null! }
cancelAnimationFrame(previewAnimRef.current)
clearPreview()
}
const togglePreviewPlay = () => {
if (!previewUrl) return
if (isPreviewPlaying) {
previewAudioRef.current?.pause()
setIsPreviewPlaying(false)
} else {
const audio = new Audio(previewUrl)
audio.onloadedmetadata = () => { if (isFinite(audio.duration)) setPreviewDuration(audio.duration) }
audio.onended = () => { setIsPreviewPlaying(false); setPreviewCurrent(0) }
audio.onerror = () => { toast.error("Failed to play preview"); setIsPreviewPlaying(false) }
previewAudioRef.current = audio
audio.play().then(() => setIsPreviewPlaying(true)).catch(() => toast.error("Failed to play preview"))
const tick = () => { if (previewAudioRef.current) setPreviewCurrent(previewAudioRef.current.currentTime); previewAnimRef.current = requestAnimationFrame(tick) }
cancelAnimationFrame(previewAnimRef.current)
previewAnimRef.current = requestAnimationFrame(tick)
}
}
const sendVoiceRecording = async () => {
if (!previewBlob || !previewUrl) return
try {
cancelAnimationFrame(previewAnimRef.current)
previewAudioRef.current?.pause()
const formData = new FormData()
const ext = previewBlob.type.includes("webm") ? "webm" : "webm"
formData.append("audio", previewBlob, `voice.${ext}`)
formData.append("duration", previewDuration.toString())
const uploadRes = await fetch("/api/upload/voice", { method: "POST", body: formData })
if (!uploadRes.ok) { toast.error("Failed to upload voice recording"); return }
const { url: serverUrl, duration } = await uploadRes.json()
const voicePayload = JSON.stringify({ v: true, u: serverUrl, d: duration || previewDuration })
await addMessageToChat(voicePayload, { url: serverUrl, duration: duration || previewDuration })
clearPreview()
} catch {
toast.error("Failed to send voice recording")
}
} }
useEffect(() => { useEffect(() => {
@@ -515,13 +828,29 @@ export default function ChatsPage() {
const isImageFile = (file: File) => file.type.startsWith("image/") const isImageFile = (file: File) => file.type.startsWith("image/")
const getDisplayContent = (content: string) => {
try {
const parsed = JSON.parse(content)
if (parsed.text !== undefined) return parsed.text
} catch {}
return content
}
const fileToDataURL = (file: File): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
return ( return (
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden"> <div className="flex h-[calc(100dvh-4rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
{/* Conversations list - left panel */} {/* ── CONVERSATIONS LIST (left panel) ── */}
<div <div
className="flex flex-col border-r shrink-0 overflow-hidden" className="flex flex-col border-r shrink-0 overflow-hidden"
style={{ width: panelWidth }} style={{ width: panelWidth }}
> >
{/* Search header */}
<div className="p-4 border-b space-y-3"> <div className="p-4 border-b space-y-3">
<h2 className="text-lg font-semibold">Chats</h2> <h2 className="text-lg font-semibold">Chats</h2>
<div className="relative"> <div className="relative">
@@ -529,6 +858,7 @@ export default function ChatsPage() {
<Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search conversations..." className="h-9 pl-9" /> <Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search conversations..." className="h-9 pl-9" />
</div> </div>
</div> </div>
{/* Scrollable conversation list */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
{filteredConversations.map((conv) => { {filteredConversations.map((conv) => {
const person = otherParticipant(conv) const person = otherParticipant(conv)
@@ -564,7 +894,7 @@ export default function ChatsPage() {
<span className="text-sm font-medium truncate">{person.name}</span> <span className="text-sm font-medium truncate">{person.name}</span>
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span> <span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
</div> </div>
<p className="text-xs text-muted-foreground truncate mt-0.5">{conv.lastMessage}</p> <p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
</div> </div>
{!isActive && (unreadMap.get(conv.id) || 0) > 0 && ( {!isActive && (unreadMap.get(conv.id) || 0) > 0 && (
<div className="shrink-0 h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444] self-center" /> <div className="shrink-0 h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444] self-center" />
@@ -620,7 +950,7 @@ export default function ChatsPage() {
</ScrollArea> </ScrollArea>
</div> </div>
{/* Resize handle */} {/* ── RESIZE HANDLE ── */}
<div <div
className="w-1.5 cursor-col-resize shrink-0 relative group hover:bg-primary/20 transition-colors" className="w-1.5 cursor-col-resize shrink-0 relative group hover:bg-primary/20 transition-colors"
onMouseDown={handleResizeStart} onMouseDown={handleResizeStart}
@@ -628,10 +958,10 @@ export default function ChatsPage() {
<div className="absolute inset-y-0 -left-1 -right-1" /> <div className="absolute inset-y-0 -left-1 -right-1" />
</div> </div>
{/* Chat area - right panel */} {/* ── CHAT AREA (right panel) ── */}
{conversation ? ( {conversation ? (
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
{/* Chat header */} {/* Chat header: avatar, name, actions */}
<div className="flex items-center justify-between gap-4 px-6 h-16 border-b shrink-0"> <div className="flex items-center justify-between gap-4 px-6 h-16 border-b shrink-0">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<Avatar className="h-9 w-9 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(otherParticipant(conversation).avatar)}> <Avatar className="h-9 w-9 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(otherParticipant(conversation).avatar)}>
@@ -647,6 +977,9 @@ export default function ChatsPage() {
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}> <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={() => setScheduleDialogOpen(true)}>
<CalendarDays 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">
@@ -671,12 +1004,22 @@ export default function ChatsPage() {
</div> </div>
</div> </div>
{/* Messages */} {/* ── MESSAGES ── */}
<div className="flex-1 overflow-y-auto p-6" style={{ scrollbarWidth: "thin", scrollbarColor: "hsl(var(--muted-foreground) / 0.3) transparent" }}> <div className="flex-1 overflow-y-auto p-6" style={{ scrollbarWidth: "thin", scrollbarColor: "hsl(var(--muted-foreground) / 0.3) transparent" }}>
<div className="space-y-4 min-h-0"> <div className="space-y-4 min-h-0">
{messages.map((msg) => { {messages.map((msg) => {
const isMe = msg.senderId === user?.id const isMe = msg.senderId === user?.id
const voiceUrl = voiceMessages.get(msg.id) let voiceUrl = voiceMessages.get(msg.id)
let gifData: { url: string; w: number; h: number } | null = null
let stickerData: { url: string; w: number; h: number; emoji?: string } | null = null
if (msg.content?.startsWith("{")) {
try {
const p = JSON.parse(msg.content)
if (p.v && p.u) voiceUrl = { url: p.u, duration: p.d || 0 }
else if (p.gif) gifData = { url: p.gif, w: p.w || 320, h: p.h || 240 }
else if (p.sticker) stickerData = { url: p.sticker, w: p.w || 200, h: p.h || 200, emoji: p.id ? undefined : p.sticker }
} catch {}
}
return ( return (
<div key={msg.id} className={cn("group flex gap-3", isMe && "flex-row-reverse")}> <div key={msg.id} className={cn("group flex gap-3", isMe && "flex-row-reverse")}>
<Avatar className="h-8 w-8 mt-0.5 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(msg.senderAvatar)}> <Avatar className="h-8 w-8 mt-0.5 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(msg.senderAvatar)}>
@@ -743,13 +1086,22 @@ export default function ChatsPage() {
<p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p> <p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p>
</div> </div>
)} )}
{voiceUrl ? ( {gifData ? (
<div> <div className="max-w-[280px]">
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} /> <img src={gifData.url} alt="GIF" className="w-full rounded-lg" style={{ aspectRatio: `${gifData.w}/${gifData.h}` }} loading="lazy" />
{msg.content && ( </div>
<p className="mt-1 text-sm text-black">{msg.content}</p> ) : stickerData ? (
<div className="max-w-[200px]">
{stickerData.emoji ? (
<span className="text-7xl block text-center">{stickerData.emoji}</span>
) : (
<div className="w-full" dangerouslySetInnerHTML={{ __html: sanitizeSvg(stickerData.url).replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
)} )}
</div> </div>
) : voiceUrl ? (
<div>
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} isOwn={isMe} />
</div>
) : ( ) : (
<> <>
{(() => { {(() => {
@@ -759,26 +1111,30 @@ export default function ChatsPage() {
{files.map((file, i) => {files.map((file, i) =>
file.type.startsWith("image/") ? ( file.type.startsWith("image/") ? (
<div key={i} className="relative group"> <div key={i} className="relative group">
<img src={file.url} alt={file.name} className="max-w-full rounded-lg max-h-60 object-cover cursor-pointer" onClick={() => window.open(file.url, "_blank")} /> <img src={file.url} alt={file.name} className="max-w-full rounded-lg max-h-60 object-cover cursor-pointer" onClick={() => setPreviewImageUrl(file.url)} />
<a href={file.url} download={file.name} className="absolute top-2 right-2 h-7 w-7 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> <a href={file.url} download={file.name} className="absolute top-2 right-2 h-7 w-7 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Download className="h-3.5 w-3.5 text-white" /> <Download className="h-3.5 w-3.5 text-white" />
</a> </a>
</div> </div>
) : ( ) : (
<a key={i} href={file.url} download={file.name} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm hover:bg-muted/80 transition-colors"> <div key={i} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm hover:bg-muted/80 transition-colors">
<button type="button" className="flex items-center gap-2 flex-1 min-w-0 text-left" onClick={() => window.open(file.url, "_blank")} title="Open">
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" /> <FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 truncate">{file.name}</span> <span className="flex-1 min-w-0 truncate">{file.name}</span>
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> </button>
<a href={file.url} download={file.name} className="shrink-0 text-muted-foreground hover:text-foreground" title="Download">
<Download className="h-3.5 w-3.5" />
</a> </a>
</div>
) )
)} )}
</div> </div>
) : null ) : null
})()} })()}
{isOnlyEmoji(msg.content) ? ( {isOnlyEmoji(getDisplayContent(msg.content)) ? (
<span className="text-4xl leading-none">{msg.content.trim()}</span> <span className="text-4xl leading-none">{getDisplayContent(msg.content).trim()}</span>
) : ( ) : (
msg.content getDisplayContent(msg.content)
)} )}
</> </>
)} )}
@@ -802,7 +1158,7 @@ export default function ChatsPage() {
</div> </div>
</div> </div>
{/* Input */} {/* ── INPUT BAR ── */}
<div className="p-4 border-t shrink-0 space-y-2"> <div className="p-4 border-t shrink-0 space-y-2">
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -847,9 +1203,27 @@ export default function ChatsPage() {
)} )}
<form onSubmit={handleSend} className="flex items-center gap-2"> <form onSubmit={handleSend} className="flex items-center gap-2">
<input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" className="hidden" onChange={handleFileSelect} /> <input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" className="hidden" onChange={handleFileSelect} />
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => fileInputRef.current?.click()}> <div className="relative">
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => setShowAttachMenu(!showAttachMenu)}>
<Paperclip className="h-4 w-4 text-muted-foreground" /> <Paperclip className="h-4 w-4 text-muted-foreground" />
</Button> </Button>
{showAttachMenu && (
<div ref={attachMenuRef} className="absolute bottom-full left-0 mb-2 z-50 bg-popover border rounded-lg shadow-lg p-1.5 min-w-[140px]">
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", "image/*"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
<Image className="h-4 w-4" /> Photos
</button>
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", ".pdf,.doc,.docx,.xls,.xlsx,.txt"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
<FileIcon className="h-4 w-4" /> Documents
</button>
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.removeAttribute("accept"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
<FolderOpen className="h-4 w-4" /> Files
</button>
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", ".eml,.msg"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
<Mail className="h-4 w-4" /> Emails
</button>
</div>
)}
</div>
<div className="relative flex-1"> <div className="relative flex-1">
{isRecording ? ( {isRecording ? (
<div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2"> <div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2">
@@ -866,9 +1240,25 @@ export default function ChatsPage() {
<Square className="h-3.5 w-3.5 fill-current" /> <Square className="h-3.5 w-3.5 fill-current" />
</Button> </Button>
</div> </div>
) : previewUrl ? (
<div className="flex h-9 items-center gap-2 rounded-md border bg-muted/30 px-3">
<button type="button" onClick={togglePreviewPlay} className="h-6 w-6 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
{isPreviewPlaying ? <Pause className="h-3.5 w-3.5 fill-current" /> : <Play className="h-3.5 w-3.5 ml-0.5 fill-current" />}
</button>
<div className="flex-1 h-1 rounded-full bg-muted-foreground/20 relative overflow-hidden">
<div className="h-full rounded-full bg-primary transition-all duration-100" style={{ width: `${previewDuration > 0 ? (previewCurrent / previewDuration) * 100 : 0}%` }} />
</div>
<span className="text-xs tabular-nums text-muted-foreground w-8 text-right">{formatDuration(Math.floor(previewCurrent))}/{formatDuration(previewDuration)}</span>
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-destructive hover:text-destructive" onClick={cancelVoicePreview}>
<Trash2 className="h-3 w-3" />
</Button>
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-primary hover:text-primary" onClick={sendVoiceRecording}>
<Send className="h-3 w-3" />
</Button>
</div>
) : ( ) : (
<> <>
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { if (!e.clipboardData) return; const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...files]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} /> <textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { if (!e.clipboardData) return; const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...filterBlockedFiles(files).allowed]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}> <div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setShowEmojiPicker(!showEmojiPicker)}> <Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setShowEmojiPicker(!showEmojiPicker)}>
<Smile className="h-4 w-4 text-muted-foreground" /> <Smile className="h-4 w-4 text-muted-foreground" />
@@ -878,14 +1268,14 @@ export default function ChatsPage() {
</Button> </Button>
{showEmojiPicker && ( {showEmojiPicker && (
<div className="absolute bottom-full right-0 mb-2 z-50"> <div className="absolute bottom-full right-0 mb-2 z-50">
<Picker data={data} onEmojiSelect={handleEmojiSelect} theme={theme === "dark" ? "dark" : "light"} previewPosition="none" skinTonePosition="none" set="native" emojiSize={36} maxFrequentRows={2} /> <MediaPicker onEmojiSelect={handleEmojiSelect} onMediaSelect={handleMediaSelect} onClose={() => setShowEmojiPicker(false)} theme={theme} />
</div> </div>
)} )}
</div> </div>
</> </>
)} )}
</div> </div>
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={!messageInput.trim() && attachments.length === 0}> <Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={(!messageInput.trim() && attachments.length === 0) || attachments.some(f => hasBlockedCodeExtension(f.name))}>
{editingMessage ? <Pencil className="h-4 w-4" /> : <Send className="h-4 w-4" />} {editingMessage ? <Pencil className="h-4 w-4" /> : <Send className="h-4 w-4" />}
</Button> </Button>
</form> </form>
@@ -897,9 +1287,11 @@ export default function ChatsPage() {
</div> </div>
)} )}
{/* Resize overlay */} {/* Resize drag overlay (captures mouse events during resize) */}
{isResizing && <div className="fixed inset-0 z-50 cursor-col-resize" />} {isResizing && <div className="fixed inset-0 z-50 cursor-col-resize" />}
{/* ── DIALOGS ── */}
{/* Report dialog */} {/* Report dialog */}
<Dialog open={reportDialogOpen} onOpenChange={setReportDialogOpen}> <Dialog open={reportDialogOpen} onOpenChange={setReportDialogOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
@@ -918,7 +1310,21 @@ export default function ChatsPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Avatar preview dialog */} {/* Image preview dialog (full-size) */}
<Dialog open={!!previewImageUrl} onOpenChange={(o) => { if (!o) setPreviewImageUrl(null) }}>
<DialogContent className="sm:max-w-3xl p-0 overflow-hidden bg-transparent border-0 shadow-none">
<DialogTitle className="sr-only">Image preview</DialogTitle>
{previewImageUrl && (
<img
src={previewImageUrl}
alt="Image preview"
className="w-full h-auto max-h-[85vh] object-contain rounded-2xl"
/>
)}
</DialogContent>
</Dialog>
{/* ── Avatar preview dialog ── */}
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}> <Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none"> <DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
{previewAvatarUrl && ( {previewAvatarUrl && (
@@ -931,7 +1337,7 @@ export default function ChatsPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Forward dialog */} {/* ── Forward message dialog ── */}
<Dialog open={forwardDialogOpen} onOpenChange={setForwardDialogOpen}> <Dialog open={forwardDialogOpen} onOpenChange={setForwardDialogOpen}>
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader> <DialogHeader>
@@ -994,8 +1400,100 @@ export default function ChatsPage() {
</ScrollArea> </ScrollArea>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} /> <VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
{/* Schedule from Chat Dialog */}
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>Schedule with {conversation ? otherParticipant(conversation).name : "..."}</DialogTitle>
<DialogDescription>Create an event and notify the participant</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="sched-title">Title</Label>
<Input id="sched-title" value={scheduleTitle} onChange={(e) => setScheduleTitle(e.target.value)} placeholder="e.g. Follow-up call" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="sched-date">Date</Label>
<Input id="sched-date" type="date" value={scheduleDate} onChange={(e) => setScheduleDate(e.target.value)} />
</div>
<div>
<Label htmlFor="sched-time">Time</Label>
<Input id="sched-time" type="time" value={scheduleTime} onChange={(e) => setScheduleTime(e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="sched-type">Type</Label>
<Select value={scheduleType} onValueChange={setScheduleType}>
<SelectTrigger id="sched-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="meeting">Meeting</SelectItem>
<SelectItem value="call">Call</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="sched-duration">Duration</Label>
<Select value={scheduleDuration} onValueChange={setScheduleDuration}>
<SelectTrigger id="sched-duration">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15">15 min</SelectItem>
<SelectItem value="30">30 min</SelectItem>
<SelectItem value="60">1 hour</SelectItem>
<SelectItem value="90">1.5 hours</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setScheduleDialogOpen(false)}>Cancel</Button>
<Button disabled={scheduleSaving || !scheduleTitle || !scheduleDate || !scheduleTime} onClick={async () => {
if (!conversation || !user) return
setScheduleSaving(true)
try {
const startTime = new Date(`${scheduleDate}T${scheduleTime}:00`).toISOString()
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(scheduleDuration))
const res = await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: scheduleTitle,
eventType: scheduleType,
startTime,
endTime: end.toISOString(),
durationMinutes: parseInt(scheduleDuration),
participantId: otherParticipant(conversation).id,
conversationId: conversation.id,
}),
})
if (!res.ok) throw new Error("Failed")
toast.success("Event scheduled! Notification sent.")
setScheduleDialogOpen(false)
setScheduleTitle("")
setScheduleDate("")
setScheduleTime("")
} catch {
toast.error("Failed to schedule event")
} finally {
setScheduleSaving(false)
}
}}>
{scheduleSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Schedule
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
+50 -10
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Dashboard Page (/(dashboard)/dashboard)
// ───────────────────────────────────────────────
// Route: /dashboard
// Purpose: Main overview page showing pipeline
// stats, trend sparklines, status/leads-per-month
// charts, and a recent-leads table. Polls the
// API every 30 seconds for live updates.
// Layout: Uses PageHeader + period selector,
// 6 stat cards, 2 charts, and a table.
// ───────────────────────────────────────────────
"use client" "use client"
import { useState, useEffect, useRef } from "react" import { useState, useEffect, useRef } from "react"
@@ -24,12 +36,25 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { DashboardStats } from "@/types" import { DashboardStats } from "@/types"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
/**
* DashboardPage
*
* Fetches dashboard stats for a selected period
* and renders stat cards, charts, and a recent-
* leads table. Includes auto-polling every 30 s.
*
* @returns Full dashboard layout with loading
* skeleton states.
*/
export default function DashboardPage() { export default function DashboardPage() {
const { websiteTheme } = useWebsiteTheme()
const [period, setPeriod] = useState("6months") const [period, setPeriod] = useState("6months")
const [stats, setStats] = useState<DashboardStats | null>(null) const [stats, setStats] = useState<DashboardStats | null>(null)
const pollingRef = useRef<NodeJS.Timeout | null>(null) const pollingRef = useRef<NodeJS.Timeout | null>(null)
// ── Fetch dashboard stats from API ──
async function fetchStats(p: string) { async function fetchStats(p: string) {
try { try {
const res = await fetch(`/api/dashboard?period=${p}`) const res = await fetch(`/api/dashboard?period=${p}`)
@@ -42,6 +67,7 @@ export default function DashboardPage() {
} }
} }
// ── Initial fetch + 30-second polling ──
useEffect(() => { useEffect(() => {
fetchStats(period) fetchStats(period)
pollingRef.current = setInterval(() => fetchStats(period), 30000) pollingRef.current = setInterval(() => fetchStats(period), 30000)
@@ -50,6 +76,7 @@ export default function DashboardPage() {
} }
}, [period]) }, [period])
// ── Build stat-card configs from API response ──
const statCards = stats const statCards = stats
? [ ? [
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const }, { title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
@@ -63,14 +90,13 @@ export default function DashboardPage() {
return ( return (
<div className="space-y-6 relative"> <div className="space-y-6 relative">
{/* Daily Bugle watermark */} <div className="relative z-[1] space-y-6">
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]"> {/* ── Header with period filter ── */}
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#e62020] dark:text-[#FF6666] tracking-wider"> <div>
DAILY BUGLE <PageHeader
</span> title={<span style={{fontFamily:"'Bangers',cursive",letterSpacing:"0.05em"}}>Dashboard</span>}
</div> description="Overview of your sales pipeline"
>
<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" />
@@ -84,8 +110,10 @@ export default function DashboardPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</PageHeader> </PageHeader>
</div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p> {/* ── Pipeline stat cards (or skeletons while loading) ── */}
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] 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
@@ -96,14 +124,26 @@ export default function DashboardPage() {
} }
</div> </div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p> {/* ── Analytics charts ── */}
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] 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 ?? []} />
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} /> <LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
</div> </div>
{/* ── Recent leads table ── */}
<RecentLeadsTable leads={stats?.recentLeads ?? []} /> <RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div> </div>
{/* ── Themed decorative overlay (Spidey theme) ── */}
{websiteTheme === "spidey" && (
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
DAILY BUGLE
</span>
</div>
)}
</div>
) )
} }
+78
View File
@@ -0,0 +1,78 @@
// ───────────────────────────────────────────────
// Emails Page (/(dashboard)/emails)
// ───────────────────────────────────────────────
// Route: /emails
// Purpose: Admin-only email log viewer. Lists
// all sent emails with subject, recipient,
// and timestamp. Extracts "With:" metadata
// from the email body text.
// Layout: Simple list with header + description.
// Access restricted to admin / super_admin.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { useUser } from "@/providers/user-provider"
import { Mail, Clock } from "lucide-react"
/**
* EmailsPage
*
* Dispatched-email log. Only available to admin
* and super_admin roles. Redirects unauthorized
* users back to /dashboard or /login.
*
* @returns Email list UI or null if unauthorized.
*/
export default function EmailsPage() {
const router = useRouter()
const { user } = useUser()
const [emails, setEmails] = useState<any[]>([])
// ── Auth guard + fetch emails ──
useEffect(() => {
if (!user) { router.push("/login"); return }
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return }
fetch("/api/emails").then((r) => r.ok ? r.json() : { emails: [] }).then((d) => setEmails(d.emails || []))
}, [user, router])
if (!user || (user.role !== "admin" && user.role !== "super_admin")) return null
return (
<div className="p-6">
<h1 className="text-xl font-bold mb-4">Email Log</h1>
<p className="text-sm text-muted-foreground mb-6">
Emails are stored locally. To actually deliver them, add <code className="bg-muted px-1 rounded">EMAIL_API_KEY</code> to your .env.
</p>
{/* ── Email list or empty state ── */}
{emails.length === 0 ? (
<p className="text-sm text-muted-foreground">No emails sent yet.</p>
) : (
<div className="space-y-2">
{emails.map((e: any) => {
// Extract "With:" metadata from email body for display
const withMatch = e.bodyText?.match(/With:\s*(.+)/)
const withName = withMatch ? withMatch[1].trim() : null
return (
<div key={e.id} className="flex items-center gap-3 p-3 rounded-lg border bg-card">
<Mail className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{e.subject}</p>
<p className="text-xs text-muted-foreground">To: {e.recipient}</p>
{withName && <p className="text-xs text-muted-foreground/70 mt-0.5">With: {withName}</p>}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="h-3 w-3" />
{new Date(e.createdAt).toLocaleString()}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
+10 -3
View File
@@ -1,5 +1,7 @@
"use client" "use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { AppShell } from "@/components/layout/app-shell" import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider" import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider" import { NotificationProvider } from "@/providers/notification-provider"
@@ -8,8 +10,15 @@ import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) { function DashboardContent({ children }: { children: React.ReactNode }) {
const { user, loading } = useUser() const { user, loading } = useUser()
const router = useRouter()
if (loading) { useEffect(() => {
if (!user && !loading) {
router.push("/login")
}
}, [user, loading, router])
if (loading || !user) {
return ( return (
<div className="flex h-screen items-center justify-center"> <div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -17,8 +26,6 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
) )
} }
if (!user) return null
return <AppShell>{children}</AppShell> return <AppShell>{children}</AppShell>
} }
+36
View File
@@ -1,3 +1,17 @@
// ───────────────────────────────────────────────
// Lead Details Page (/(dashboard)/leads/[id])
// ───────────────────────────────────────────────
// Route: /leads/:id
// Purpose: Full detail view for a single lead.
// Displays company/contact info, notes timeline,
// activity log, and quick actions. Supports
// inline status changes with optimistic UI.
// Layout: 3-column grid — 2-col main area
// (details card + notes) + 1-col sidebar
// (activity + quick actions). Uses framer-motion
// for staggered entrance animations.
// ───────────────────────────────────────────────
"use client" "use client"
import { useState, useEffect, useCallback } from "react" import { useState, useEffect, useCallback } from "react"
@@ -20,17 +34,31 @@ import {
import { ArrowLeft, Edit, ExternalLink } from "lucide-react" import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
import { Lead, Note } from "@/types" import { Lead, Note } from "@/types"
/**
* LeadDetailsPage
*
* Fetches lead + notes in parallel on mount.
* Handles loading, not-found, and error states.
* Status select uses optimistic updates with
* rollback on failure.
*
* @param params - Next.js route params (Promise
* unwrapped via React.use()).
* @returns Lead detail layout.
*/
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) { export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params) const { id } = use(params)
const [lead, setLead] = useState<Lead | null>(null) const [lead, setLead] = useState<Lead | null>(null)
const [leadNotes, setLeadNotes] = useState<Note[]>([]) const [leadNotes, setLeadNotes] = useState<Note[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// ── Fetch notes for this lead ──
const fetchNotes = useCallback(async () => { const fetchNotes = useCallback(async () => {
const notesRes = await fetch(`/api/leads/${id}/notes`) const notesRes = await fetch(`/api/leads/${id}/notes`)
if (notesRes.ok) setLeadNotes(await notesRes.json()) if (notesRes.ok) setLeadNotes(await notesRes.json())
}, [id]) }, [id])
// ── Initial data load ──
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
try { try {
@@ -47,6 +75,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
fetchData() fetchData()
}, [id, fetchNotes]) }, [id, fetchNotes])
// ── Loading state ──
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-[60vh]"> <div className="flex items-center justify-center h-[60vh]">
@@ -55,6 +84,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
) )
} }
// ── Not-found state ──
if (!lead) { if (!lead) {
return ( return (
<div className="flex items-center justify-center h-[60vh]"> <div className="flex items-center justify-center h-[60vh]">
@@ -74,6 +104,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* ── Back link ── */}
<Link <Link
href="/leads" href="/leads"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
@@ -82,6 +113,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
Back to leads Back to leads
</Link> </Link>
{/* ── Header: company name, status badge, status select, edit button ── */}
<PageHeader <PageHeader
title={ title={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -94,6 +126,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
<Select <Select
value={lead.status} value={lead.status}
onValueChange={async (v) => { onValueChange={async (v) => {
// Optimistic update — revert on fail
const previousStatus = lead.status const previousStatus = lead.status
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev) setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
try { try {
@@ -125,7 +158,9 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
</Button> </Button>
</PageHeader> </PageHeader>
{/* ── Main content grid ── */}
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{/* ── Left: Details + Notes ── */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@@ -152,6 +187,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
</motion.div> </motion.div>
</div> </div>
{/* ── Right sidebar: Activity + Quick Actions ── */}
<div className="space-y-6"> <div className="space-y-6">
<motion.div <motion.div
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: 20 }}
+30
View File
@@ -1,3 +1,16 @@
// ───────────────────────────────────────────────
// Create Lead Page (/(dashboard)/leads/new)
// ───────────────────────────────────────────────
// Route: /leads/new
// Purpose: Form to create a new lead. Uses
// react-hook-form + zod for validation.
// On success, fires a notification and
// redirects to the leads list.
// Layout: Back link, heading, Card with form
// fields in a 2-col grid + textarea + user
// assignment select.
// ───────────────────────────────────────────────
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
@@ -30,6 +43,7 @@ import { User } from "@/types"
import { useNotifications } from "@/providers/notification-provider" import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants" import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
/** Zod schema for lead creation form validation. */
const leadFormSchema = z.object({ const leadFormSchema = z.object({
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
contactName: z.string().min(1, "Contact name is required"), contactName: z.string().min(1, "Contact name is required"),
@@ -43,12 +57,22 @@ const leadFormSchema = z.object({
type LeadFormValues = z.infer<typeof leadFormSchema> type LeadFormValues = z.infer<typeof leadFormSchema>
/**
* CreateLeadPage
*
* Zod-validated lead creation form. Fetches the
* user list for lead assignment on mount. On
* submit POSTs to /api/leads and navigates back.
*
* @returns Lead creation form layout.
*/
export default function CreateLeadPage() { export default function CreateLeadPage() {
const router = useRouter() const router = useRouter()
const { addNotification } = useNotifications() const { addNotification } = useNotifications()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
// ── Fetch available users for assignment ──
useEffect(() => { useEffect(() => {
fetch("/api/users") fetch("/api/users")
.then((r) => r.json()) .then((r) => r.json())
@@ -70,9 +94,11 @@ export default function CreateLeadPage() {
}, },
}) })
// ── Submit handler ──
async function onSubmit(values: LeadFormValues) { async function onSubmit(values: LeadFormValues) {
setSaving(true) setSaving(true)
try { try {
// Transform "none" sentinel value to null
const payload = { const payload = {
...values, ...values,
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null), assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
@@ -84,6 +110,7 @@ export default function CreateLeadPage() {
}) })
if (!res.ok) throw new Error("Failed to create lead") if (!res.ok) throw new Error("Failed to create lead")
const data = await res.json() const data = await res.json()
// Notify other parts of the app
addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, `/leads/${data.id}`) addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, `/leads/${data.id}`)
router.push("/leads") router.push("/leads")
} catch (e) { } catch (e) {
@@ -95,6 +122,7 @@ export default function CreateLeadPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* ── Back navigation ── */}
<Link <Link
href="/leads" href="/leads"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
@@ -108,6 +136,7 @@ export default function CreateLeadPage() {
<p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p> <p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p>
</div> </div>
{/* ── Lead form ── */}
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<Form {...form}> <Form {...form}>
@@ -256,6 +285,7 @@ export default function CreateLeadPage() {
</FormItem> </FormItem>
)} )}
/> />
{/* ── Submit / Cancel buttons ── */}
<div className="flex items-center gap-3 pt-2"> <div className="flex items-center gap-3 pt-2">
<Button type="submit" disabled={saving}> <Button type="submit" disabled={saving}>
{saving ? "Saving..." : "Create Lead"} {saving ? "Saving..." : "Create Lead"}
+23
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Leads List Page (/(dashboard)/leads)
// ───────────────────────────────────────────────
// Route: /leads
// Purpose: List, search, filter, and navigate to
// leads. Supports status/period filters and a
// text search. "Create" button navigates to
// /leads/new.
// Layout: PageHeader + toolbar (search + filters)
// + LeadsTable.
// ───────────────────────────────────────────────
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
@@ -7,6 +19,15 @@ import { LeadsTable } from "@/components/leads/leads-table"
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar" import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
import { Lead } from "@/types" import { Lead } from "@/types"
/**
* LeadsPage
*
* Fetches leads from the API whenever search,
* status, or period filters change. Passes data
* and loading state to the table and toolbar.
*
* @returns Leads list layout.
*/
export default function LeadsPage() { export default function LeadsPage() {
const router = useRouter() const router = useRouter()
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
@@ -15,6 +36,7 @@ export default function LeadsPage() {
const [leadsData, setLeadsData] = useState<Lead[]>([]) const [leadsData, setLeadsData] = useState<Lead[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// ── Fetch leads with current filters ──
useEffect(() => { useEffect(() => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (search) params.set("search", search) if (search) params.set("search", search)
@@ -38,6 +60,7 @@ export default function LeadsPage() {
description="Manage and track your sales leads" description="Manage and track your sales leads"
/> />
{/* ── Toolbar + Table ── */}
<div className="rounded-lg border bg-card"> <div className="rounded-lg border bg-card">
<div className="p-4"> <div className="p-4">
<LeadsTableToolbar <LeadsTableToolbar
+28
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Profile Page (/(dashboard)/profile)
// ───────────────────────────────────────────────
// Route: /profile
// Purpose: View and edit the current user's
// profile — avatar upload, personal info,
// role, join date, and status.
// Layout: Two-column grid — left card with avatar
// + metadata badges, right card with account
// details in a 2-column grid.
// ───────────────────────────────────────────────
"use client" "use client"
import { useRef } from "react" import { useRef } from "react"
@@ -9,19 +21,31 @@ import { useUser } from "@/providers/user-provider"
import { Mail, Calendar, Shield, Activity, Camera } from "lucide-react" import { Mail, Calendar, Shield, Activity, Camera } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
/**
* ProfilePage
*
* Displays the currently logged-in user's profile.
* Allows avatar upload (PNG/JPEG) via a hidden
* file input with a hover-triggered camera overlay.
*
* @returns Profile layout or null if no user.
*/
export default function ProfilePage() { export default function ProfilePage() {
const { user, updateAvatar } = useUser() const { user, updateAvatar } = useUser()
if (!user) return null if (!user) return null
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
// ── Avatar upload handler ──
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
// Validate file type — only PNG and JPEG accepted
const validTypes = ["image/png", "image/jpeg"] const validTypes = ["image/png", "image/jpeg"]
if (!validTypes.includes(file.type)) { if (!validTypes.includes(file.type)) {
toast.error("Only PNG and JPEG files are allowed") toast.error("Only PNG and JPEG files are allowed")
return return
} }
// Read as data URL, then POST to API
const reader = new FileReader() const reader = new FileReader()
reader.onload = async () => { reader.onload = async () => {
const dataUrl = reader.result as string const dataUrl = reader.result as string
@@ -51,8 +75,10 @@ export default function ProfilePage() {
<PageHeader title="Profile" description="Your account information" /> <PageHeader title="Profile" description="Your account information" />
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{/* ── Left column: Avatar + quick info ── */}
<Card className="lg:col-span-1"> <Card className="lg:col-span-1">
<CardContent className="flex flex-col items-center pt-8"> <CardContent className="flex flex-col items-center pt-8">
{/* Avatar with hover-to-upload overlay */}
<div className="relative"> <div className="relative">
<Avatar className="h-24 w-24"> <Avatar className="h-24 w-24">
<AvatarImage src={user.avatar} /> <AvatarImage src={user.avatar} />
@@ -77,6 +103,7 @@ export default function ProfilePage() {
<Badge variant="secondary" className="mt-1 capitalize"> <Badge variant="secondary" className="mt-1 capitalize">
{user.role} {user.role}
</Badge> </Badge>
{/* Metadata rows */}
<div className="mt-6 flex w-full flex-col gap-3 text-sm"> <div className="mt-6 flex w-full flex-col gap-3 text-sm">
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3"> <div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
<Mail className="h-4 w-4 text-muted-foreground" /> <Mail className="h-4 w-4 text-muted-foreground" />
@@ -100,6 +127,7 @@ export default function ProfilePage() {
</CardContent> </CardContent>
</Card> </Card>
{/* ── Right column: Full account details ── */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
+26 -4
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Settings Page (/(dashboard)/settings)
// ───────────────────────────────────────────────
// Route: /settings
// Purpose: Tabbed settings panel covering company
// info, user preferences, theme choices, and
// notification configuration.
// Layout: PageHeader + <Tabs> with 4 tab panels.
// Each tab renders a dedicated settings form
// component.
// ───────────────────────────────────────────────
"use client" "use client"
import { PageHeader } from "@/components/shared/page-header" import { PageHeader } from "@/components/shared/page-header"
@@ -8,14 +20,23 @@ import { ThemeSettings } from "@/components/settings/theme-settings"
import { NotificationSettings } from "@/components/settings/notification-settings" import { NotificationSettings } from "@/components/settings/notification-settings"
import { Building2, User, Palette, Bell } from "lucide-react" import { Building2, User, Palette, Bell } from "lucide-react"
export default function SettingsPage() { /** Tab configuration: value, label, icon, and the component to render. */
const tabs = [ const settingsTabs = [
{ value: "company", label: "Company", icon: Building2, component: CompanySettingsForm }, { value: "company", label: "Company", icon: Building2, component: CompanySettingsForm },
{ value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm }, { value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm },
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings }, { value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings }, { value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
] ]
/**
* SettingsPage
*
* Renders a tabbed settings interface. Each tab
* dynamically renders its associated form component.
*
* @returns Settings layout with navigation tabs.
*/
export default function SettingsPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageHeader <PageHeader
@@ -23,16 +44,17 @@ export default function SettingsPage() {
description="Manage your CRM configuration and preferences" description="Manage your CRM configuration and preferences"
/> />
{/* ── Tabbed settings sections ── */}
<Tabs defaultValue="company" className="space-y-6"> <Tabs defaultValue="company" className="space-y-6">
<TabsList> <TabsList>
{tabs.map((tab) => ( {settingsTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value} className="gap-2"> <TabsTrigger key={tab.value} value={tab.value} className="gap-2">
<tab.icon className="h-4 w-4" /> <tab.icon className="h-4 w-4" />
{tab.label} {tab.label}
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
{tabs.map((tab) => ( {settingsTabs.map((tab) => (
<TabsContent key={tab.value} value={tab.value}> <TabsContent key={tab.value} value={tab.value}>
<tab.component /> <tab.component />
</TabsContent> </TabsContent>
+29
View File
@@ -1,3 +1,14 @@
// ───────────────────────────────────────────────
// Users Page (/(dashboard)/users)
// ───────────────────────────────────────────────
// Route: /users
// Purpose: Team management — list, search, and
// create users. Shows summary stats cards and
// a searchable table.
// Layout: PageHeader + stat grid + search bar +
// UsersTable + inline UserFormDialog.
// ───────────────────────────────────────────────
"use client" "use client"
import { useState, useEffect, useCallback } from "react" import { useState, useEffect, useCallback } from "react"
@@ -10,6 +21,16 @@ import { useUser } from "@/providers/user-provider"
import { Plus, Users as UsersIcon, Search } from "lucide-react" import { Plus, Users as UsersIcon, Search } from "lucide-react"
import type { User } from "@/types" import type { User } from "@/types"
/**
* UsersPage
*
* Fetches all users on mount, computes summary
* stats (total / active / admins / sales), and
* renders a searchable table. "Add User" button
* opens a modal dialog for creation.
*
* @returns Users management layout.
*/
export default function UsersPage() { export default function UsersPage() {
const { user } = useUser() const { user } = useUser()
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
@@ -17,6 +38,7 @@ export default function UsersPage() {
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
// ── Fetch all users ──
const fetchUsers = useCallback(async () => { const fetchUsers = useCallback(async () => {
try { try {
const res = await fetch("/api/users") const res = await fetch("/api/users")
@@ -35,6 +57,7 @@ export default function UsersPage() {
fetchUsers() fetchUsers()
}, [fetchUsers]) }, [fetchUsers])
// ── Derived stats ──
const activeUsers = users.filter((u) => u.active !== false) const activeUsers = users.filter((u) => u.active !== false)
const admins = users.filter((u) => u.role === "admin") const admins = users.filter((u) => u.role === "admin")
@@ -45,6 +68,7 @@ export default function UsersPage() {
{ label: "Sales", value: users.filter((u) => u.role === "sales").length, color: "bg-orange-500/10", textColor: "text-orange-500" }, { label: "Sales", value: users.filter((u) => u.role === "sales").length, color: "bg-orange-500/10", textColor: "text-orange-500" },
] ]
// ── Client-side search filter by name/email ──
const filtered = users.filter((u) => { const filtered = users.filter((u) => {
if (!search) return true if (!search) return true
const q = search.toLowerCase() const q = search.toLowerCase()
@@ -53,6 +77,7 @@ export default function UsersPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* ── Header with "Add User" action ── */}
<PageHeader <PageHeader
title="Users" title="Users"
description="Manage team members and their roles" description="Manage team members and their roles"
@@ -63,6 +88,7 @@ export default function UsersPage() {
</Button> </Button>
</PageHeader> </PageHeader>
{/* ── Summary stat cards ── */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
{stats.map((s) => ( {stats.map((s) => (
<div key={s.label} className="rounded-lg border bg-card p-4"> <div key={s.label} className="rounded-lg border bg-card p-4">
@@ -81,6 +107,7 @@ export default function UsersPage() {
))} ))}
</div> </div>
{/* ── Search input ── */}
<div className="relative"> <div className="relative">
<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-muted-foreground" />
<Input <Input
@@ -91,10 +118,12 @@ export default function UsersPage() {
/> />
</div> </div>
{/* ── Users data table ── */}
<div className="rounded-lg border bg-card"> <div className="rounded-lg border bg-card">
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} /> <UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
</div> </div>
{/* ── Create-user dialog ── */}
<UserFormDialog <UserFormDialog
open={createOpen} open={createOpen}
onOpenChange={setCreateOpen} onOpenChange={setCreateOpen}
+10
View File
@@ -1,7 +1,16 @@
// ── AI: Chat ─────────────────────────────────────────────────────────────────
// POST /api/ai/chat — Send a message to the AI assistant and get a response
//
// Auth: authenticated
// Body: { message: string }
// Response: { response: string }
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { chatWithAI } from "@/lib/ai" import { chatWithAI } from "@/lib/ai"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const user = await getSessionUser() const user = await getSessionUser()
@@ -14,6 +23,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Message is required" }, { status: 400 }) return NextResponse.json({ error: "Message is required" }, { status: 400 })
} }
// Extract JWT from session cookie to forward to the AI service for auth
const sessionCookie = request.cookies.get("session") const sessionCookie = request.cookies.get("session")
const jwtToken = sessionCookie?.value const jwtToken = sessionCookie?.value
if (!jwtToken) { if (!jwtToken) {
+71
View File
@@ -0,0 +1,71 @@
// ── AI: Giphy Integration ────────────────────────────────────────────────────
// GET /api/ai/giphy — Proxy for GIPHY API (search or trending)
//
// Auth: authenticated
// Query params: type=search|trending, q=search query, offset, limit
// Returns a curated subset of GIF fields suitable for chat/UI integration.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
// ── Constants ────────────────────────────────────────────────────────────────
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
if (!GIPHY_API_KEY) {
return NextResponse.json({ error: "GIPHY API key not configured" }, { status: 500 })
}
const { searchParams } = new URL(request.url)
const type = searchParams.get("type") || "trending"
const query = searchParams.get("q") || ""
const offset = parseInt(searchParams.get("offset") || "0", 10)
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
// Route to search or trending endpoint based on type param
let url: string
if (type === "search" && query) {
url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}&rating=g`
} else {
url = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${offset}&rating=g`
}
const res = await fetch(url)
if (!res.ok) {
const errBody = await res.text()
console.error("GIPHY API error:", res.status, errBody)
return NextResponse.json({ error: `GIPHY API error (${res.status}): ${errBody}` }, { status: res.status })
}
const data = await res.json()
// Normalize the response to a minimal set of fields used by the UI
const gifs = (data.data || []).map((gif: any) => ({
id: gif.id,
title: gif.title,
url: gif.images?.original?.url || "",
previewUrl: gif.images?.fixed_width?.url || "",
previewHeight: gif.images?.fixed_width?.height || 150,
width: gif.images?.original?.width || 0,
height: gif.images?.original?.height || 0,
}))
return NextResponse.json({
gifs,
pagination: data.pagination || { total_count: 0, count: gifs.length, offset },
})
} catch (error: any) {
console.error("GIPHY proxy error:", error)
return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 })
}
}
+8
View File
@@ -1,7 +1,15 @@
// ── AI: Job Listing ──────────────────────────────────────────────────────────
// GET /api/ai/jobs — Fetch AI-related job postings
//
// Auth: authenticated; role must be sales, admin, or super_admin
// Returns an empty array on failure (graceful degradation).
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
import { fetchJobs } from "@/lib/ai" import { fetchJobs } from "@/lib/ai"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() { export async function GET() {
try { try {
const user = await getSessionUser() const user = await getSessionUser()
-11
View File
@@ -1,11 +0,0 @@
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 })
}
+52 -16
View File
@@ -1,4 +1,10 @@
import { NextRequest, NextResponse } from "next/server" // ── Auth: Login ──────────────────────────────────────────────────────────────
// POST /api/auth/login
// Authenticates a user with email/username + password and returns a JWT session
// cookie. Supports account lockout, credential validation, and login audit
// logging. Returns user data on success, error details on failure.
import { NextRequest } from "next/server"
import { import {
comparePassword, comparePassword,
getUserByEmail, getUserByEmail,
@@ -9,28 +15,44 @@ import {
resetFailedAttempts, resetFailedAttempts,
isAccountLocked, isAccountLocked,
createSession, createSession,
setSessionContext,
SESSION_COOKIE,
} from "@/lib/auth" } from "@/lib/auth"
// ── Helpers ──────────────────────────────────────────────────────────────────
function jsonResponse(data: unknown, status: number) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
})
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { email, username, password } = await request.json() const { email, username, password } = await request.json()
const credential = email || username const credential = email || username
// Validate that both credential and password are present
if (!credential || !password) { if (!credential || !password) {
return NextResponse.json( return jsonResponse(
{ error: "Email/Username and password are required." }, { error: "Email/Username and password are required." },
{ status: 400 } 400
) )
} }
// Reject empty strings (whitespace-only credentials)
if (credential.trim().length === 0 || password.trim().length === 0) { if (credential.trim().length === 0 || password.trim().length === 0) {
return NextResponse.json( return jsonResponse(
{ error: "Credentials cannot be empty." }, { error: "Credentials cannot be empty." },
{ status: 400 } 400
) )
} }
// Extract client IP from proxy headers for audit logging
const ipAddress = const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") || request.headers.get("x-real-ip") ||
@@ -48,6 +70,7 @@ export async function POST(request: NextRequest) {
dbUser = await getUserByUsername(credential) dbUser = await getUserByUsername(credential)
} }
// If user does not exist, log attempt and return generic error
if (!dbUser) { if (!dbUser) {
await recordLoginAttempt( await recordLoginAttempt(
null, null,
@@ -57,12 +80,13 @@ export async function POST(request: NextRequest) {
false, false,
"User not found" "User not found"
) )
return NextResponse.json( return jsonResponse(
{ error: "Invalid email/username or password." }, { error: "Invalid email/username or password." },
{ status: 401 } 401
) )
} }
// Check if account is temporarily locked due to too many failed attempts
const lockStatus = await isAccountLocked(dbUser) const lockStatus = await isAccountLocked(dbUser)
if (lockStatus.locked) { if (lockStatus.locked) {
await recordLoginAttempt( await recordLoginAttempt(
@@ -73,12 +97,13 @@ export async function POST(request: NextRequest) {
false, false,
lockStatus.reason lockStatus.reason
) )
return NextResponse.json( return jsonResponse(
{ error: lockStatus.reason }, { error: lockStatus.reason },
{ status: 423 } 423
) )
} }
// Verify password hash against stored hash
const valid = await comparePassword(password, dbUser.password_hash) const valid = await comparePassword(password, dbUser.password_hash)
if (!valid) { if (!valid) {
await incrementFailedAttempts(dbUser.id) await incrementFailedAttempts(dbUser.id)
@@ -90,12 +115,13 @@ export async function POST(request: NextRequest) {
false, false,
"Invalid password" "Invalid password"
) )
return NextResponse.json( return jsonResponse(
{ error: "Invalid email/username or password." }, { error: "Invalid email/username or password." },
{ status: 401 } 401
) )
} }
// Successful login: reset failure counter, log success, create session
await resetFailedAttempts(dbUser.id) await resetFailedAttempts(dbUser.id)
await recordLoginAttempt( await recordLoginAttempt(
dbUser.id, dbUser.id,
@@ -105,16 +131,26 @@ export async function POST(request: NextRequest) {
true true
) )
await createSession(dbUser.id, dbUser.role_name) const token = await createSession(dbUser.id, dbUser.role_name)
await setSessionContext(dbUser.id, ipAddress)
const user = mapDbUserToSessionUser(dbUser) const user = mapDbUserToSessionUser(dbUser)
return NextResponse.json({ user }, { status: 200 }) // Set HttpOnly session cookie with configurable Secure flag in production
const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}`
return new Response(JSON.stringify({ user }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": cookieStr,
},
})
} catch (error) { } catch (error) {
console.error("Login error:", error) console.error("Login error:", error)
return NextResponse.json( return new Response(
{ error: "Authentication service unavailable." }, JSON.stringify({ error: "Authentication service unavailable." }),
{ status: 503 } { status: 503, headers: { "Content-Type": "application/json" } }
) )
} }
} }
+19 -7
View File
@@ -1,15 +1,27 @@
import { NextResponse } from "next/server" // ── Auth: Logout ─────────────────────────────────────────────────────────────
import { destroySession } from "@/lib/auth" // POST /api/auth/logout
// Clears the session cookie by setting Max-Age=0, effectively logging the
// user out. Stateless — no server-side session invalidation needed.
import { SESSION_COOKIE } from "@/lib/auth"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST() { export async function POST() {
try { try {
await destroySession() // Overwrite cookie with an immediate expiry (Max-Age=0)
return NextResponse.json({ success: true }, { status: 200 }) return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${process.env.NODE_ENV === "production" ? "; Secure" : ""}`,
},
})
} catch (error) { } catch (error) {
console.error("Logout error:", error) console.error("Logout error:", error)
return NextResponse.json( return new Response(
{ error: "Logout failed." }, JSON.stringify({ error: "Logout failed." }),
{ status: 500 } { status: 500, headers: { "Content-Type": "application/json" } }
) )
} }
} }

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