Compare commits
187 Commits
cd797c5b3c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 45cc54be89 | |||
| d35c806d5b | |||
| c9c855579b | |||
| 3a348e3616 | |||
| dba4c84cd5 | |||
| d77ff2b965 | |||
| 0bc3ca58ed | |||
| d793604e92 | |||
| bc83af8e00 | |||
| 80dee367e8 | |||
| da99b695be | |||
| dd6767980a | |||
| c1c4afadbb | |||
| 37679a7a60 | |||
| c0cb715ced | |||
| 1269f6cce8 | |||
| 370f935cbb | |||
| faf9dd551a | |||
| f67d9377a0 | |||
| 38fb3a47a8 | |||
| bc422edcf7 | |||
| da5f8360bc | |||
| 37af1febcc | |||
| 84632e043f | |||
| b2bb6d94f5 | |||
| 566fa3df88 | |||
| 16710e3019 | |||
| e920d4925a | |||
| e8d80c3a16 | |||
| 77cccba8a1 | |||
| 7a5e833722 | |||
| 1e8f979cf9 | |||
| 98145f0264 | |||
| 7a722a7d7e | |||
| 61e9fac352 | |||
| 878627a6ba | |||
| 2c3a8e5333 | |||
| d870fb3ac7 | |||
| f6d1d91fc6 | |||
| bc3e345a27 | |||
| 3c0e7d76ca | |||
| 39fb39db12 | |||
| cd37d5a987 | |||
| d138c60203 | |||
| 4d7a59c278 | |||
| aac9817ee7 | |||
| fa97abb5b6 | |||
| 3998285fd5 | |||
| 4c1db42873 | |||
| c428435f2f | |||
| 886a7efe91 | |||
| 1b5f244f28 | |||
| 46a3216386 | |||
| 8aba5c9ef4 | |||
| 06181625c3 | |||
| 08eba574f8 | |||
| a876f2d769 | |||
| fbb37d6777 | |||
| 214c4a1852 | |||
| c669ec0d04 | |||
| bf6fbe3ee4 | |||
| d6534eb338 | |||
| a08f23ab0f | |||
| bccba1434b | |||
| fe09d15359 | |||
| 2b8f2e7b59 | |||
| fb99b49686 | |||
| 75746f4cab | |||
| a022e3cca3 | |||
| 68483e9b60 | |||
| 21868fe336 | |||
| 77b246b565 | |||
| 34e64fe6c7 | |||
| ffa595451e | |||
| bd581739f9 | |||
| ec8207fca7 | |||
| ec4de03cf5 | |||
| dfba947aa4 | |||
| 9bd795e39a | |||
| 66ae711cf0 | |||
| 827b0598e6 | |||
| 650116872b | |||
| 5e3c12fa2d | |||
| 7a76841309 | |||
| 283c06f0cf | |||
| 60892d6151 | |||
| 131d4c8f94 | |||
| 1f448320be | |||
| 9ce9506e8e | |||
| ed2e1fc64d | |||
| 5feb95187c | |||
| a4d2ad143b | |||
| da702d6beb | |||
| 33a4f9dfa9 | |||
| 82e8ce8ae9 | |||
| 343f814569 | |||
| 4c09635d30 | |||
| 20a1744e7f | |||
| c9df7787d5 | |||
| fa39f2af9d | |||
| 9bbaf70145 | |||
| 5668a63370 | |||
| 3af622f53d | |||
| 1465016b56 | |||
| 906e37e845 | |||
| 52a489759f | |||
| 1f032dc098 | |||
| 1adb6544c2 | |||
| 56ff9995a2 | |||
| 33753a1291 | |||
| 06912f3e59 | |||
| f7258d38f6 | |||
| 4c1d994054 | |||
| b245b352e7 | |||
| 9acbbb6a19 | |||
| 8a6ad5c6c6 | |||
| b2a2f7e40f | |||
| 2b4749e5e1 | |||
| e872ab9736 | |||
| 4d9a1dc684 | |||
| 203eac2132 | |||
| 97e8da3bfd | |||
| 3a927b25b6 | |||
| b4be369f25 | |||
| 4fd9f7752a | |||
| e78503b5c1 | |||
| d51c84997a | |||
| c355d0e2e5 | |||
| ff56cea4b8 | |||
| 7bd9c17b5f | |||
| 1c717ce7ba | |||
| cc56fe6286 | |||
| cf554a70cd | |||
| 3061ab111c | |||
| 491ff52b90 | |||
| 1adc4806fa | |||
| d891197d8b | |||
| b81f391df5 | |||
| 9cbae2022b | |||
| d6e4908c18 | |||
| ba947cea87 | |||
| 95e87c8429 | |||
| c5766d1624 | |||
| 6c88dcca7b | |||
| a517b91de1 | |||
| 61c0a9e83f | |||
| 0bae5bb746 | |||
| be5808f3fc | |||
| 571af27f50 | |||
| e5726fdbb2 | |||
| ebc29c47a4 | |||
| 7255bae281 | |||
| 8e27a25ff1 | |||
| 6ffbc7c9b4 | |||
| adbcc4b9af | |||
| f5d09298a2 | |||
| 02ef2b9f1f | |||
| 6fe41b6c96 | |||
| 1e4b8df8dd | |||
| e7e4d371c2 | |||
| b19a00c6c2 | |||
| 13555c3fab | |||
| 0880c13890 | |||
| 5b115ae9ff | |||
| 3d03440ffa | |||
| 10b5de7a21 | |||
| 0898ad4ebf | |||
| 03fbe13938 | |||
| ca96f4b185 | |||
| e6a6face98 | |||
| a0d8420a2f | |||
| c6ce93c564 | |||
| 24dc4855d7 | |||
| de6cbfccc2 | |||
| 178677aa85 | |||
| 277c719963 | |||
| 5238b59140 | |||
| 8cb49167f4 | |||
| fd2d4ff976 | |||
| 7a42e3c41c | |||
| f1a1db5ff6 | |||
| 3f839bc0fc | |||
| c29fd287c4 | |||
| ee44e9fc47 | |||
| 0fead80e1f | |||
| d6d784cef3 | |||
| 4898bf7142 |
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
.vscode
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env
|
||||||
|
.gitignore
|
||||||
|
.prettierrc
|
||||||
@@ -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
|
||||||
+63
-5
@@ -1,8 +1,66 @@
|
|||||||
node_modules/
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
.next/
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/node_modules_old
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
# runtime data
|
||||||
|
data/
|
||||||
|
data/ai/
|
||||||
|
data/ai/jobs.jsonl
|
||||||
|
|
||||||
|
# logs
|
||||||
*.log
|
*.log
|
||||||
out/
|
*.out
|
||||||
build/
|
error-log
|
||||||
.next/
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# python
|
||||||
|
browser-use-service/venv/
|
||||||
|
browser-use-service/__pycache__/
|
||||||
|
browser-use-service/.env
|
||||||
|
|
||||||
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
.git
|
||||||
|
.git_bak
|
||||||
|
node_modules
|
||||||
|
target
|
||||||
|
# rust build artifacts
|
||||||
|
rust-ai/target/
|
||||||
|
|
||||||
|
# browser-use-service generated files
|
||||||
|
browser-use-service/*.txt
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/node_modules_old
|
|
||||||
/.pnp
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
|
||||||
.env*
|
|
||||||
|
|
||||||
# runtime data
|
|
||||||
data/
|
|
||||||
data/ai/
|
|
||||||
data/ai/jobs.jsonl
|
|
||||||
|
|
||||||
# logs
|
|
||||||
*.log
|
|
||||||
*.out
|
|
||||||
error-log
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# rust
|
|
||||||
rust-ai/target/
|
|
||||||
|
|
||||||
# python
|
|
||||||
browser-use-service/venv/
|
|
||||||
browser-use-service/__pycache__/
|
|
||||||
browser-use-service/.env
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
import http from "node:http"
|
|
||||||
import fs from "node:fs"
|
|
||||||
import path from "node:path"
|
|
||||||
import { fileURLToPath } from "node:url"
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
||||||
const ROOT = path.resolve(__dirname, "..")
|
|
||||||
|
|
||||||
// ── Load .env.local ──────────────────────────────────────────────
|
|
||||||
try {
|
|
||||||
const envPath = path.join(ROOT, ".env.local")
|
|
||||||
const envContent = fs.readFileSync(envPath, "utf-8")
|
|
||||||
for (const line of envContent.split("\n")) {
|
|
||||||
const trimmed = line.trim()
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue
|
|
||||||
const eqIdx = trimmed.indexOf("=")
|
|
||||||
if (eqIdx === -1) continue
|
|
||||||
const k = trimmed.substring(0, eqIdx).trim()
|
|
||||||
let v = trimmed.substring(eqIdx + 1).trim()
|
|
||||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
|
|
||||||
if (!process.env[k]) process.env[k] = v
|
|
||||||
}
|
|
||||||
console.log("Loaded .env.local")
|
|
||||||
} catch {
|
|
||||||
// .env.local may not exist, ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Config from env ─────────────────────────────────────────────
|
|
||||||
const PORT = parseInt(process.env.AI_PORT || "3001", 10)
|
|
||||||
const HOST = process.env.AI_HOST || "0.0.0.0"
|
|
||||||
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
|
||||||
const MODEL = process.env.AI_MODEL || "sam860/dolphin3-llama3.2:3b"
|
|
||||||
const DATABASE_URL = process.env.DATABASE_URL
|
|
||||||
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
|
|
||||||
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
|
|
||||||
|
|
||||||
// ── Job loading ─────────────────────────────────────────────────
|
|
||||||
function loadJobs() {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
|
||||||
const jobs = content
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((l) => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(l)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
console.log(`Loaded ${jobs.length} job categories`)
|
|
||||||
return jobs
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to load jobs from ${JOBS_PATH}:`, e.message)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ai.md management ────────────────────────────────────────────
|
|
||||||
function readInstructions() {
|
|
||||||
try {
|
|
||||||
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
|
||||||
} catch {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeInstructions(content) {
|
|
||||||
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendToImprovementLog(entry) {
|
|
||||||
const current = readInstructions()
|
|
||||||
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
|
|
||||||
const logEntry = `\n- ${timestamp} — ${entry}`
|
|
||||||
const logSection = "\n## Improvement Log"
|
|
||||||
const logIndex = current.indexOf(logSection)
|
|
||||||
if (logIndex !== -1) {
|
|
||||||
const afterLog = current.substring(logIndex + logSection.length)
|
|
||||||
const nextSectionIndex = afterLog.search(/\n## /)
|
|
||||||
if (nextSectionIndex !== -1) {
|
|
||||||
const insertAt = logIndex + logSection.length + nextSectionIndex
|
|
||||||
const updated = current.substring(0, insertAt) + logEntry + "\n" + current.substring(insertAt)
|
|
||||||
writeInstructions(updated)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
writeInstructions(current + logEntry + "\n")
|
|
||||||
return current + logEntry + "\n"
|
|
||||||
}
|
|
||||||
const updated = current + `\n${logSection}\n${logEntry}\n`
|
|
||||||
writeInstructions(updated)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chat handler ────────────────────────────────────────────────
|
|
||||||
async function handleChat(userMessage, userId, userRole) {
|
|
||||||
const jobs = loadedJobs
|
|
||||||
const instructions = readInstructions()
|
|
||||||
|
|
||||||
const jobList = jobs
|
|
||||||
.map((j) => `- ${j.job_title} (${j.industry}): ${j.description}`)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
const systemPrompt = `You are a Sales AI Assistant for Coast IT CRM. Your role is to help salespeople with tips, strategies, and guidance.
|
|
||||||
|
|
||||||
Available job categories to target:
|
|
||||||
${jobList}
|
|
||||||
|
|
||||||
Current instructions:
|
|
||||||
${instructions}
|
|
||||||
|
|
||||||
Provide concise, actionable sales advice. When asked about a specific job category, give targeted tips on finding and engaging prospects in that field. Keep responses under 300 words unless asked for detail.`
|
|
||||||
|
|
||||||
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: MODEL,
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: systemPrompt },
|
|
||||||
{ role: "user", content: userMessage },
|
|
||||||
],
|
|
||||||
stream: false,
|
|
||||||
options: { temperature: 0.7, num_predict: 1024 },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!ollamaRes.ok) {
|
|
||||||
const text = await ollamaRes.text()
|
|
||||||
throw new Error(`Ollama error (${ollamaRes.status}): ${text}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await ollamaRes.json()
|
|
||||||
const responseText = data.message?.content || ""
|
|
||||||
|
|
||||||
// Try to persist to DB (best-effort)
|
|
||||||
try {
|
|
||||||
if (pgPool && userId) {
|
|
||||||
await pgPool.query(
|
|
||||||
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
|
||||||
[crypto.randomUUID(), userId, userRole || "sales", userMessage, responseText]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// table might not exist, ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseText
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── PG pool (lazy init) ────────────────────────────────────────
|
|
||||||
let pgPool = null
|
|
||||||
async function initPg() {
|
|
||||||
if (!DATABASE_URL) return
|
|
||||||
try {
|
|
||||||
const { default: pg } = await import("pg")
|
|
||||||
pgPool = new pg.Pool({ connectionString: DATABASE_URL, max: 5 })
|
|
||||||
await pgPool.query("SELECT 1")
|
|
||||||
console.log("Connected to PostgreSQL")
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("PostgreSQL unavailable (AI convos won't be saved):", e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Request router ─────────────────────────────────────────────
|
|
||||||
const loadedJobs = loadJobs()
|
|
||||||
|
|
||||||
function sendJSON(res, status, data) {
|
|
||||||
res.writeHead(status, { "Content-Type": "application/json" })
|
|
||||||
res.end(JSON.stringify(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBody(req) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let body = ""
|
|
||||||
req.on("data", (chunk) => (body += chunk))
|
|
||||||
req.on("end", () => {
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(body))
|
|
||||||
} catch {
|
|
||||||
reject(new Error("Invalid JSON"))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
req.on("error", reject)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseURL(req) {
|
|
||||||
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
|
||||||
return { pathname: url.pathname, searchParams: url.searchParams }
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
|
||||||
// CORS
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
res.writeHead(204)
|
|
||||||
res.end()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pathname } = parseURL(req)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// GET /health
|
|
||||||
if (req.method === "GET" && pathname === "/health") {
|
|
||||||
return sendJSON(res, 200, { status: "ok", model: MODEL })
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /ai/jobs
|
|
||||||
if (req.method === "GET" && pathname === "/ai/jobs") {
|
|
||||||
return sendJSON(res, 200, { jobs: loadedJobs })
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /ai/instructions
|
|
||||||
if (req.method === "GET" && pathname === "/ai/instructions") {
|
|
||||||
const instructions = readInstructions()
|
|
||||||
return sendJSON(res, 200, { success: true, instructions })
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /ai/instructions
|
|
||||||
if (req.method === "POST" && pathname === "/ai/instructions") {
|
|
||||||
const body = await parseBody(req)
|
|
||||||
if (body.content) {
|
|
||||||
writeInstructions(body.content)
|
|
||||||
} else if (body.entry) {
|
|
||||||
appendToImprovementLog(body.entry)
|
|
||||||
}
|
|
||||||
return sendJSON(res, 200, {
|
|
||||||
success: true,
|
|
||||||
instructions: readInstructions(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /ai/chat
|
|
||||||
if (req.method === "POST" && pathname === "/ai/chat") {
|
|
||||||
const startTime = Date.now()
|
|
||||||
const chunks = []
|
|
||||||
req.on("data", c => chunks.push(c))
|
|
||||||
req.on("end", () => {
|
|
||||||
const rawBody = Buffer.concat(chunks).toString()
|
|
||||||
try { fs.appendFileSync("C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-req-log.txt",
|
|
||||||
`${new Date().toISOString()} headers=${JSON.stringify(req.headers)} body=${rawBody}\n`) } catch {}
|
|
||||||
try {
|
|
||||||
const body = JSON.parse(rawBody)
|
|
||||||
// Continue processing
|
|
||||||
processRequest(req, res, body, startTime)
|
|
||||||
} catch {
|
|
||||||
sendJSON(res, 400, { error: "Invalid JSON" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate handler
|
|
||||||
async function processRequest(req, res, body, startTime) {
|
|
||||||
const { message, user_id, user_role } = body
|
|
||||||
|
|
||||||
if (!message) {
|
|
||||||
return sendJSON(res, 400, { error: "message is required" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const validRoles = ["sales", "admin", "super_admin"]
|
|
||||||
if (user_role && !validRoles.includes(user_role)) {
|
|
||||||
return sendJSON(res, 403, { error: "Forbidden" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await handleChat(message, user_id || "", user_role || "sales")
|
|
||||||
return sendJSON(res, 200, { response })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 404
|
|
||||||
sendJSON(res, 404, { error: "Not found" })
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Request error:", err)
|
|
||||||
sendJSON(res, 500, { error: err.message })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Start ───────────────────────────────────────────────────────
|
|
||||||
server.listen(PORT, HOST, () => {
|
|
||||||
console.log(`CRM AI server listening on http://${HOST}:${PORT}`)
|
|
||||||
console.log(` Model: ${MODEL}`)
|
|
||||||
console.log(` Ollama: ${OLLAMA_URL}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
initPg()
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from fastapi import FastAPI, Query
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
import uvicorn
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["http://localhost:3006", "http://127.0.0.1:3006"],
|
|
||||||
allow_methods=["POST"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
PORT = int(os.getenv("PORT", "3008"))
|
|
||||||
|
|
||||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
|
||||||
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
|
|
||||||
|
|
||||||
FX_PROFILE = os.getenv('FX_PROFILE', '')
|
|
||||||
|
|
||||||
BROAD_KEYWORDS = [
|
|
||||||
"website", "web design", "web develop", "web dev",
|
|
||||||
"build my website", "build a website", "create a website",
|
|
||||||
"need web", "looking for web", "new website",
|
|
||||||
"landing page", "wordpress",
|
|
||||||
"need a website", "my website", "business website",
|
|
||||||
"need a designer", "help with my website",
|
|
||||||
"redesign", "update my website",
|
|
||||||
"looking for", "need a", "need an", "looking to",
|
|
||||||
"help me build", "create my", "build me",
|
|
||||||
"design my", "make me a", "would like",
|
|
||||||
"need someone", "hire a", "looking to hire",
|
|
||||||
"want someone", "need help with",
|
|
||||||
]
|
|
||||||
|
|
||||||
def kw_match(text: str) -> bool:
|
|
||||||
t = text.lower()
|
|
||||||
return any(kw in t for kw in BROAD_KEYWORDS)
|
|
||||||
|
|
||||||
FB_SEARCHES = [
|
|
||||||
"looking for web developer",
|
|
||||||
"need a website designed",
|
|
||||||
"need website built South Africa",
|
|
||||||
"need someone to build my website",
|
|
||||||
"need web designer",
|
|
||||||
"looking for someone to create website",
|
|
||||||
"need ecommerce website built",
|
|
||||||
"want to hire web developer",
|
|
||||||
"website quote please",
|
|
||||||
"need wordpress website",
|
|
||||||
"I need a website for my business",
|
|
||||||
"need a site for my business",
|
|
||||||
]
|
|
||||||
|
|
||||||
VIEWPORTS = [
|
|
||||||
{'width': 1280, 'height': 800},
|
|
||||||
{'width': 1366, 'height': 768},
|
|
||||||
{'width': 1440, 'height': 900},
|
|
||||||
{'width': 1536, 'height': 864},
|
|
||||||
{'width': 1920, 'height': 1080},
|
|
||||||
]
|
|
||||||
|
|
||||||
async def get_fb_cookies(profile_path: str | None = None):
|
|
||||||
cookie_db_path = profile_path or FX_PROFILE
|
|
||||||
if not cookie_db_path:
|
|
||||||
logger.warning("No profile path provided")
|
|
||||||
return []
|
|
||||||
cookie_db = os.path.join(cookie_db_path, 'cookies.sqlite')
|
|
||||||
if not os.path.exists(cookie_db):
|
|
||||||
logger.warning("Cookie DB not found at %s", cookie_db)
|
|
||||||
return []
|
|
||||||
tmp = os.path.join(os.path.dirname(cookie_db), f'cookies_tmp_{os.getpid()}.sqlite')
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
shutil.copy2(cookie_db, tmp)
|
|
||||||
break
|
|
||||||
except PermissionError:
|
|
||||||
if attempt < 2:
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
else:
|
|
||||||
logger.error("Failed to copy cookie DB after 3 attempts")
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(tmp)
|
|
||||||
c = conn.cursor()
|
|
||||||
c.execute("SELECT name, value, host, path FROM moz_cookies WHERE host LIKE '%facebook.com'")
|
|
||||||
rows = c.fetchall()
|
|
||||||
conn.close()
|
|
||||||
try:
|
|
||||||
os.remove(tmp)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return [{
|
|
||||||
"name": name, "value": value,
|
|
||||||
"domain": host if host.startswith('.') else f'.{host}',
|
|
||||||
"path": path,
|
|
||||||
"httpOnly": True, "secure": True, "sameSite": "Lax",
|
|
||||||
} for name, value, host, path in rows]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Cookie DB read error: %s", e)
|
|
||||||
try:
|
|
||||||
os.remove(tmp)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return []
|
|
||||||
|
|
||||||
WEEKDAY_ORDER = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
|
||||||
|
|
||||||
def _parse_fb_date(block: list[str]) -> str:
|
|
||||||
for l in block:
|
|
||||||
l = l.strip()
|
|
||||||
m = re.match(r'(\d+)\s*(h|hr|hrs|hour|hours)\s*ago', l)
|
|
||||||
if m:
|
|
||||||
hours = int(m.group(1))
|
|
||||||
d = datetime.now() - timedelta(hours=hours)
|
|
||||||
return d.strftime('%Y-%m-%d')
|
|
||||||
if l.lower() == 'yesterday':
|
|
||||||
d = datetime.now() - timedelta(days=1)
|
|
||||||
return d.strftime('%Y-%m-%d')
|
|
||||||
low = l.lower()
|
|
||||||
if low in WEEKDAY_ORDER:
|
|
||||||
day_idx = WEEKDAY_ORDER.index(low)
|
|
||||||
today_idx = datetime.now().weekday()
|
|
||||||
days_ago = (today_idx - day_idx) % 7
|
|
||||||
d = datetime.now() - timedelta(days=days_ago)
|
|
||||||
return d.strftime('%Y-%m-%d')
|
|
||||||
for fmt in ['%Y-%m-%d', '%d %B %Y', '%B %d %Y', '%d %b %Y', '%b %d %Y', '%d %b', '%b %d']:
|
|
||||||
try:
|
|
||||||
dt = datetime.strptime(l, fmt)
|
|
||||||
return dt.strftime('%Y-%m-%d')
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return datetime.now().strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|
||||||
lines = [l.strip() for l in raw.split('\n')]
|
|
||||||
blocks = []
|
|
||||||
cur = []
|
|
||||||
fb_run = 0
|
|
||||||
for l in lines:
|
|
||||||
if 'facebook' in l.lower():
|
|
||||||
fb_run += 1
|
|
||||||
if cur and fb_run >= 2:
|
|
||||||
blocks.append(cur)
|
|
||||||
cur = []
|
|
||||||
else:
|
|
||||||
fb_run = 0
|
|
||||||
if l and len(l) >= 15:
|
|
||||||
words = re.findall(r'[A-Za-z]{2,}', l)
|
|
||||||
if len(words) >= 2:
|
|
||||||
cur.append(l)
|
|
||||||
if cur:
|
|
||||||
blocks.append(cur)
|
|
||||||
|
|
||||||
posts = []
|
|
||||||
seen_texts = set()
|
|
||||||
for block in blocks:
|
|
||||||
if not block:
|
|
||||||
continue
|
|
||||||
kw_indices = [i for i, l in enumerate(block) if kw_match(l)]
|
|
||||||
if not kw_indices:
|
|
||||||
continue
|
|
||||||
i = kw_indices[0]
|
|
||||||
start = max(0, i - 2)
|
|
||||||
end = min(len(block), i + 5)
|
|
||||||
snippet = ' '.join(block[start:end])
|
|
||||||
if len(snippet) < 40:
|
|
||||||
continue
|
|
||||||
dekey = snippet[:80]
|
|
||||||
if dekey in seen_texts:
|
|
||||||
continue
|
|
||||||
seen_texts.add(dekey)
|
|
||||||
posts.append({
|
|
||||||
"title": snippet[:300],
|
|
||||||
"content": snippet[:1000],
|
|
||||||
"author": block[start] if start < i else '',
|
|
||||||
"url": url,
|
|
||||||
"source": "facebook",
|
|
||||||
"date": _parse_fb_date(block),
|
|
||||||
})
|
|
||||||
return posts
|
|
||||||
|
|
||||||
async def human_scroll(page, steps: int = None, total_delay: float = None):
|
|
||||||
steps = steps or random.randint(2, 5)
|
|
||||||
total_delay = total_delay or random.uniform(6, 18)
|
|
||||||
step_delay = total_delay / steps
|
|
||||||
for i in range(steps):
|
|
||||||
scroll_dist = random.randint(200, 600)
|
|
||||||
await page.evaluate(f"window.scrollBy(0, {scroll_dist})")
|
|
||||||
await page.wait_for_timeout(int(step_delay * 1000))
|
|
||||||
if random.random() < 0.3:
|
|
||||||
await page.evaluate(f"window.scrollBy(0, -{random.randint(100, 300)})")
|
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
|
||||||
|
|
||||||
async def random_idle(page):
|
|
||||||
action = random.random()
|
|
||||||
if action < 0.15:
|
|
||||||
try:
|
|
||||||
elems = await page.query_selector_all('a, span, div[role="button"]')
|
|
||||||
if elems:
|
|
||||||
el = random.choice(elems[:20])
|
|
||||||
box = await el.bounding_box()
|
|
||||||
if box:
|
|
||||||
x = box['x'] + box['width'] / 2 + random.randint(-20, 20)
|
|
||||||
y = box['y'] + box['height'] / 2 + random.randint(-10, 10)
|
|
||||||
await page.mouse.move(x, y, steps=random.randint(20, 40))
|
|
||||||
await page.wait_for_timeout(random.randint(500, 2000))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def search_facebook(page, query: str) -> list[dict]:
|
|
||||||
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
|
||||||
try:
|
|
||||||
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
|
||||||
await page.wait_for_timeout(random.randint(3000, 8000))
|
|
||||||
|
|
||||||
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(6, 15))
|
|
||||||
|
|
||||||
# Accidental overscroll: 10% chance to jump to bottom and back
|
|
||||||
if random.random() < 0.1:
|
|
||||||
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
|
||||||
await page.evaluate("window.scrollBy(0, -300)")
|
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
|
||||||
|
|
||||||
if random.random() < 0.2:
|
|
||||||
await page.evaluate("window.scrollTo(0, 0)")
|
|
||||||
await page.wait_for_timeout(random.randint(2000, 5000))
|
|
||||||
|
|
||||||
if random.random() < 0.3:
|
|
||||||
await random_idle(page)
|
|
||||||
|
|
||||||
raw = await page.evaluate('document.body.innerText')
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Facebook search failed: %s", e)
|
|
||||||
return []
|
|
||||||
return _extract_posts_from_text(raw, url)
|
|
||||||
|
|
||||||
def cleanup_chrome():
|
|
||||||
import subprocess, signal
|
|
||||||
try:
|
|
||||||
subprocess.run(["taskkill", "/F", "/IM", "chrome-headless-shell.exe"], capture_output=True, timeout=5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict:
|
|
||||||
cleanup_chrome()
|
|
||||||
fb_cookies = await get_fb_cookies(profile_path)
|
|
||||||
if not fb_cookies:
|
|
||||||
logger.warning("No Facebook cookies available")
|
|
||||||
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No cookies available"}
|
|
||||||
try:
|
|
||||||
async with async_playwright() as pw:
|
|
||||||
browser = await pw.chromium.launch(
|
|
||||||
headless=True,
|
|
||||||
args=['--disable-blink-features=AutomationControlled']
|
|
||||||
)
|
|
||||||
viewport = random.choice(VIEWPORTS)
|
|
||||||
context = await browser.new_context(
|
|
||||||
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
|
|
||||||
viewport=viewport,
|
|
||||||
)
|
|
||||||
|
|
||||||
await context.add_init_script("""
|
|
||||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
|
||||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
|
||||||
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
|
||||||
window.chrome = { runtime: {} };
|
|
||||||
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
|
||||||
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
|
||||||
""")
|
|
||||||
for c in fb_cookies:
|
|
||||||
try:
|
|
||||||
await context.add_cookies([c])
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
|
|
||||||
|
|
||||||
page = await context.new_page()
|
|
||||||
# Navigate through google first for a legitimate Referer header
|
|
||||||
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
|
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
|
||||||
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
|
||||||
await page.wait_for_timeout(random.randint(3000, 8000))
|
|
||||||
|
|
||||||
url = page.url
|
|
||||||
if '/login' in url.lower():
|
|
||||||
logger.warning("Facebook login page detected — account flagged")
|
|
||||||
await browser.close()
|
|
||||||
return {"success": False, "leads": [], "flagged": True, "flag_reason": "login_page", "error": "Facebook login page detected"}
|
|
||||||
|
|
||||||
# Browse feed like a human before searching
|
|
||||||
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
|
|
||||||
if random.random() < 0.25:
|
|
||||||
await page.evaluate("window.scrollTo(0, 0)")
|
|
||||||
await page.wait_for_timeout(random.randint(2000, 5000))
|
|
||||||
await human_scroll(page, steps=random.randint(1, 2))
|
|
||||||
|
|
||||||
# False start: 30% chance to idle and leave without scraping (skipped when force=true)
|
|
||||||
if not force and random.random() < 0.3:
|
|
||||||
await page.wait_for_timeout(random.randint(8000, 20000))
|
|
||||||
await browser.close()
|
|
||||||
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
|
||||||
|
|
||||||
all_posts = []
|
|
||||||
searches = random.sample(FB_SEARCHES, k=random.randint(3, 6))
|
|
||||||
for i, query in enumerate(searches):
|
|
||||||
try:
|
|
||||||
posts = await search_facebook(page, query)
|
|
||||||
all_posts.extend(posts)
|
|
||||||
# Between searches: random break with possible small scroll
|
|
||||||
if random.random() < 0.4:
|
|
||||||
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
|
|
||||||
delay = random.uniform(8, 25)
|
|
||||||
await page.wait_for_timeout(int(delay * 1000))
|
|
||||||
# Tab switch: 15% chance after 2nd-3rd search
|
|
||||||
if i == random.randint(1, 2) and random.random() < 0.15:
|
|
||||||
new_page = await context.new_page()
|
|
||||||
await new_page.goto('https://www.messenger.com/', wait_until='domcontentloaded', timeout=15000)
|
|
||||||
await new_page.wait_for_timeout(random.randint(3000, 8000))
|
|
||||||
await new_page.close()
|
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Facebook search '%s' failed: %s", query, e)
|
|
||||||
|
|
||||||
# Idle before closing — human would pause
|
|
||||||
if random.random() < 0.5:
|
|
||||||
await page.wait_for_timeout(random.randint(3000, 10000))
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
seen = set()
|
|
||||||
deduped = []
|
|
||||||
for p in all_posts:
|
|
||||||
key = p.get('content', '')[:100]
|
|
||||||
if key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
deduped.append(p)
|
|
||||||
|
|
||||||
leads = deduped[:20]
|
|
||||||
if leads:
|
|
||||||
leads = await classify_leads(leads)
|
|
||||||
|
|
||||||
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Facebook scrape failed: %s", e)
|
|
||||||
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
|
|
||||||
|
|
||||||
async def ask_ollama(prompt: str) -> str:
|
|
||||||
import httpx
|
|
||||||
async with httpx.AsyncClient(timeout=120) as c:
|
|
||||||
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
|
|
||||||
"model": CLASSIFY_MODEL,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": "You classify forum posts. Return only valid JSON."},
|
|
||||||
{"role": "user", "content": prompt}
|
|
||||||
],
|
|
||||||
"stream": False,
|
|
||||||
"options": {"temperature": 0.05, "num_predict": 1024},
|
|
||||||
})
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
return data["message"]["content"]
|
|
||||||
|
|
||||||
async def classify_leads(results: list[dict]) -> list[dict]:
|
|
||||||
if not results:
|
|
||||||
return []
|
|
||||||
briefs = [r["title"][:200] for r in results]
|
|
||||||
prompt = f"""Classify each post as LEAD or NOT.
|
|
||||||
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
|
|
||||||
LEAD examples: "Need a website for my business", "Looking for web developer to build my site", "I need someone to create my website", "Want a new website for my company", "Looking for someone to design my WordPress site"
|
|
||||||
|
|
||||||
NOT LEAD:
|
|
||||||
- Offering web design services: "I build websites", "I offer web design", "Affordable web design packages"
|
|
||||||
- Already have a website and need marketing, SEO, content, video, link building, email marketing, affiliates
|
|
||||||
- Recruiting employees, hiring staff, looking for business partners
|
|
||||||
- Selling products, promoting services, affiliate offers
|
|
||||||
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
|
|
||||||
|
|
||||||
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
|
|
||||||
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
|
|
||||||
Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|
||||||
ai_succeeded = False
|
|
||||||
try:
|
|
||||||
raw = await ask_ollama(prompt)
|
|
||||||
raw = raw.strip()
|
|
||||||
if raw.startswith("```"):
|
|
||||||
first_nl = raw.find('\n')
|
|
||||||
if first_nl != -1:
|
|
||||||
raw = raw[first_nl + 1:]
|
|
||||||
if raw.endswith("```"):
|
|
||||||
raw = raw[:-3]
|
|
||||||
raw = raw.strip()
|
|
||||||
answers = json.loads(raw)
|
|
||||||
if isinstance(answers, list) and len(answers) == len(results):
|
|
||||||
ai_succeeded = True
|
|
||||||
filtered = []
|
|
||||||
for i, ans in enumerate(answers):
|
|
||||||
if isinstance(ans, str) and ans.lower() == 'yes':
|
|
||||||
filtered.append(results[i])
|
|
||||||
if filtered:
|
|
||||||
return filtered[:10]
|
|
||||||
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
|
|
||||||
|
|
||||||
if not ai_succeeded:
|
|
||||||
strict_keywords = [
|
|
||||||
"website", "web design", "web develop", "web dev",
|
|
||||||
"build my website", "build a website", "create a website",
|
|
||||||
"need web", "looking for web", "new website",
|
|
||||||
"landing page", "wordpress",
|
|
||||||
"need a website", "my website", "business website",
|
|
||||||
"need a designer", "help with my website",
|
|
||||||
"redesign", "update my website",
|
|
||||||
]
|
|
||||||
filtered = []
|
|
||||||
for r in results:
|
|
||||||
t = r['title'].lower()
|
|
||||||
if not any(kw in t for kw in strict_keywords):
|
|
||||||
continue
|
|
||||||
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
|
|
||||||
'affordable web', 'web hosting', 'free website',
|
|
||||||
'limited time', 'special offer', 'sign up now',
|
|
||||||
'offer']):
|
|
||||||
continue
|
|
||||||
filtered.append(r)
|
|
||||||
return filtered[:10]
|
|
||||||
return []
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health():
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.post("/scrape/facebook")
|
|
||||||
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)):
|
|
||||||
result = await scrape_facebook(profile_path, force)
|
|
||||||
return result
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- CRM Database — Full Migration Runner
|
|
||||||
-- ============================================================================
|
|
||||||
-- Usage: psql -U postgres -d crm -f run_all.sql
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
\set ON_ERROR_STOP on
|
|
||||||
|
|
||||||
\echo '=== Running 001_schema.sql (Tables + Constraints + Functions + Views) ==='
|
|
||||||
\i 001_schema.sql
|
|
||||||
|
|
||||||
\echo '=== Running 002_seed.sql (RBAC + Test Accounts + Reference Data) ==='
|
|
||||||
\i 002_seed.sql
|
|
||||||
|
|
||||||
\echo '=== Running 003_chat.sql (Chat Tables) ==='
|
|
||||||
\i 003_chat.sql
|
|
||||||
|
|
||||||
\echo '=== Running 004_avatar_url.sql (Avatar URL Column) ==='
|
|
||||||
\i 004_avatar_url.sql
|
|
||||||
|
|
||||||
\echo '=== Running 005_last_read_at.sql (Last Read At Column) ==='
|
|
||||||
\i 005_last_read_at.sql
|
|
||||||
|
|
||||||
\echo '=== Running 006_test_leads.sql (Test Lead Data) ==='
|
|
||||||
\i 006_test_leads.sql
|
|
||||||
|
|
||||||
\echo '=== Running 007_ai_features.sql (AI Features) ==='
|
|
||||||
\i 007_ai_features.sql
|
|
||||||
|
|
||||||
\echo '=== Running 008_notifications.sql (Notifications + Preferences) ==='
|
|
||||||
\i 008_notifications.sql
|
|
||||||
|
|
||||||
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
|
||||||
\i 009_settings.sql
|
|
||||||
|
|
||||||
\echo '=== Migration Complete ==='
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
||||||
import nextTs from "eslint-config-next/typescript";
|
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
|
||||||
...nextVitals,
|
|
||||||
...nextTs,
|
|
||||||
// Override default ignores of eslint-config-next.
|
|
||||||
globalIgnores([
|
|
||||||
// Default ignores of eslint-config-next:
|
|
||||||
".next/**",
|
|
||||||
"out/**",
|
|
||||||
"build/**",
|
|
||||||
"next-env.d.ts",
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default eslintConfig;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { NextConfig } from "next"
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: false,
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "ui-avatars.com",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default nextConfig
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "frontend",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
|
||||||
"dev:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"",
|
|
||||||
"dev:next": "next dev -p 3006",
|
|
||||||
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
|
|
||||||
"dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
|
|
||||||
"dev:rust": "node ai-server/index.mjs",
|
|
||||||
"dev:browser-use": "cd browser-use-service && python main.py",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start -p 3006",
|
|
||||||
"lint": "eslint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@emoji-mart/data": "^1.2.1",
|
|
||||||
"@emoji-mart/react": "^1.1.1",
|
|
||||||
"@hookform/resolvers": "^3.9.1",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
|
||||||
"@radix-ui/react-radio-group": "^1.2.3",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
|
||||||
"@tanstack/react-table": "^8.20.6",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"bcryptjs": "^3.0.3",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"framer-motion": "^11.15.0",
|
|
||||||
"jose": "^6.2.3",
|
|
||||||
"lucide-react": "^0.468.0",
|
|
||||||
"next": "^15.0.7",
|
|
||||||
"next-themes": "^0.4.4",
|
|
||||||
"pg": "^8.21.0",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-hook-form": "^7.54.2",
|
|
||||||
"recharts": "^2.15.0",
|
|
||||||
"sonner": "^1.7.4",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
|
||||||
"vaul": "^1.1.2",
|
|
||||||
"zod": "^3.24.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"@types/react": "^18",
|
|
||||||
"@types/react-dom": "^18",
|
|
||||||
"concurrently": "^10.0.3",
|
|
||||||
"eslint": "^9",
|
|
||||||
"eslint-config-next": "15.0.4",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"typescript": "^5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# CRM AI Service — Self-Knowledge
|
|
||||||
|
|
||||||
## Identity
|
|
||||||
You are the CRM AI Sales Assistant running on a Rust backend (axum + tokio).
|
|
||||||
You use Ollama with an uncensored local model (dolphin3-llama3.2:3b).
|
|
||||||
Your purpose is to help salespeople close more deals.
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
```
|
|
||||||
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)
|
|
||||||
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`.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useCallback } from "react"
|
|
||||||
import { AIChat } from "@/components/ai/ai-chat"
|
|
||||||
import { JobSelector } from "@/components/ai/job-selector"
|
|
||||||
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react"
|
|
||||||
|
|
||||||
export default function AIAssistantPage() {
|
|
||||||
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
|
|
||||||
|
|
||||||
const handleJobSelect = useCallback((job: typeof selectedJob) => {
|
|
||||||
setSelectedJob(job)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
|
||||||
<div className="border-b border-[#2a2a35] px-6 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center">
|
|
||||||
<Bot className="h-5 w-5 text-[#1BB0CE]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1>
|
|
||||||
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 flex">
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]">
|
|
||||||
<AIChat />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
|
||||||
<Target className="h-3.5 w-3.5" />
|
|
||||||
Target Job
|
|
||||||
</h3>
|
|
||||||
<JobSelector onSelect={handleJobSelect} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedJob && (
|
|
||||||
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
|
||||||
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{selectedJob.keywords.map((kw, i) => (
|
|
||||||
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]">
|
|
||||||
{kw}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react"
|
|
||||||
import { PageHeader } from "@/components/shared/page-header"
|
|
||||||
import { StatCard } from "@/components/dashboard/stat-card"
|
|
||||||
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
|
|
||||||
import { RecentLeadsTable } from "@/components/dashboard/recent-leads-table"
|
|
||||||
import { LeadStatusChart } from "@/components/dashboard/lead-status-chart"
|
|
||||||
import { LeadsPerMonthChart } from "@/components/dashboard/leads-per-month-chart"
|
|
||||||
import {
|
|
||||||
Users,
|
|
||||||
UserPlus,
|
|
||||||
PhoneCall,
|
|
||||||
Clock,
|
|
||||||
CheckCircle2,
|
|
||||||
TrendingUp,
|
|
||||||
ListFilter,
|
|
||||||
} from "lucide-react"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { DashboardStats } from "@/types"
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const [period, setPeriod] = useState("6months")
|
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
|
||||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
|
||||||
|
|
||||||
async function fetchStats(p: string) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/dashboard?period=${p}`)
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
setStats(data)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.warn("Failed to fetch dashboard stats")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStats(period)
|
|
||||||
pollingRef.current = setInterval(() => fetchStats(period), 30000)
|
|
||||||
return () => {
|
|
||||||
if (pollingRef.current) clearInterval(pollingRef.current)
|
|
||||||
}
|
|
||||||
}, [period])
|
|
||||||
|
|
||||||
const statCards = stats
|
|
||||||
? [
|
|
||||||
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
|
|
||||||
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, description: "Needs attention", trend: stats.trends.openLeads, sparklineField: "open" as const },
|
|
||||||
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, description: "In conversation", trend: stats.trends.contactedLeads, sparklineField: "contacted" as const },
|
|
||||||
{ title: "Pending", value: stats.pendingLeads, icon: Clock, description: "Awaiting decision", trend: stats.trends.pendingLeads, sparklineField: "pending" as const },
|
|
||||||
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, description: "Won", trend: stats.trends.closedLeads, sparklineField: "closed" as const },
|
|
||||||
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, description: `${stats.closedLeads} of ${stats.totalLeads} leads`, trend: stats.trends.conversionRate, sparklineField: "closed" as const, conversionRate: stats.conversionRate },
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 relative">
|
|
||||||
{/* Daily Bugle watermark */}
|
|
||||||
<div className="absolute top-12 right-4 pointer-events-none select-none z-0" style={{opacity: 0.15}}>
|
|
||||||
<span className="text-[96px] font-['Bangers',cursive] leading-none tracking-wider" style={{color: "#e62020"}}>
|
|
||||||
DAILY BUGLE
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PageHeader title={<span style={{fontFamily:"'Bangers',cursive"}}>Dashboard</span>} description="Overview of your sales pipeline">
|
|
||||||
<Select value={period} onValueChange={setPeriod}>
|
|
||||||
<SelectTrigger className="h-9 w-[160px]">
|
|
||||||
<ListFilter className="mr-2 h-4 w-4" />
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="7days">Last 7 days</SelectItem>
|
|
||||||
<SelectItem value="30days">Last 30 days</SelectItem>
|
|
||||||
<SelectItem value="6months">Last 6 months</SelectItem>
|
|
||||||
<SelectItem value="12months">Last 12 months</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
|
||||||
{stats
|
|
||||||
? statCards.map((card, i) => (
|
|
||||||
<StatCard key={card.title} {...card} index={i} monthlyBreakdown={stats.monthlyBreakdown} />
|
|
||||||
))
|
|
||||||
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
|
||||||
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { PageHeader } from "@/components/shared/page-header"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { CompanySettingsForm } from "@/components/settings/company-settings-form"
|
|
||||||
import { UserPreferencesForm } from "@/components/settings/user-preferences-form"
|
|
||||||
import { ThemeSettings } from "@/components/settings/theme-settings"
|
|
||||||
import { NotificationSettings } from "@/components/settings/notification-settings"
|
|
||||||
import { Building2, User, Palette, Bell } from "lucide-react"
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const tabs = [
|
|
||||||
{ value: "company", label: "Company", icon: Building2, component: CompanySettingsForm },
|
|
||||||
{ value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm },
|
|
||||||
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
|
|
||||||
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<PageHeader
|
|
||||||
title="Settings"
|
|
||||||
description="Manage your CRM configuration and preferences"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs defaultValue="company" className="space-y-6">
|
|
||||||
<TabsList>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<TabsTrigger key={tab.value} value={tab.value} className="gap-2">
|
|
||||||
<tab.icon className="h-4 w-4" />
|
|
||||||
{tab.label}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<TabsContent key={tab.value} value={tab.value}>
|
|
||||||
<tab.component />
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
|
||||||
|
|
||||||
// This route handler has a known issue with Next.js 15 fetch augmentation
|
|
||||||
// on this platform. The client-side code calls the AI server directly
|
|
||||||
// via browser fetch (CORS is open). This handler returns a fallback.
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
return NextResponse.json({ error: "AI service unavailable on server. Use client-side mode." }, { status: 503 })
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
|
||||||
import {
|
|
||||||
comparePassword,
|
|
||||||
getUserByEmail,
|
|
||||||
getUserByUsername,
|
|
||||||
mapDbUserToSessionUser,
|
|
||||||
recordLoginAttempt,
|
|
||||||
incrementFailedAttempts,
|
|
||||||
resetFailedAttempts,
|
|
||||||
isAccountLocked,
|
|
||||||
createSession,
|
|
||||||
} from "@/lib/auth"
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { email, username, password } = await request.json()
|
|
||||||
|
|
||||||
const credential = email || username
|
|
||||||
|
|
||||||
if (!credential || !password) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Email/Username and password are required." },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (credential.trim().length === 0 || password.trim().length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Credentials cannot be empty." },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ipAddress =
|
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
||||||
request.headers.get("x-real-ip") ||
|
|
||||||
"127.0.0.1"
|
|
||||||
|
|
||||||
const userAgent = request.headers.get("user-agent") || null
|
|
||||||
|
|
||||||
// Try to find user by email first, then by username
|
|
||||||
let dbUser =
|
|
||||||
email || credential.includes("@")
|
|
||||||
? await getUserByEmail(credential)
|
|
||||||
: null
|
|
||||||
|
|
||||||
if (!dbUser) {
|
|
||||||
dbUser = await getUserByUsername(credential)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dbUser) {
|
|
||||||
await recordLoginAttempt(
|
|
||||||
null,
|
|
||||||
credential,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
false,
|
|
||||||
"User not found"
|
|
||||||
)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid email/username or password." },
|
|
||||||
{ status: 401 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const lockStatus = await isAccountLocked(dbUser)
|
|
||||||
if (lockStatus.locked) {
|
|
||||||
await recordLoginAttempt(
|
|
||||||
dbUser.id,
|
|
||||||
credential,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
false,
|
|
||||||
lockStatus.reason
|
|
||||||
)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: lockStatus.reason },
|
|
||||||
{ status: 423 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await comparePassword(password, dbUser.password_hash)
|
|
||||||
if (!valid) {
|
|
||||||
await incrementFailedAttempts(dbUser.id)
|
|
||||||
await recordLoginAttempt(
|
|
||||||
dbUser.id,
|
|
||||||
credential,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
false,
|
|
||||||
"Invalid password"
|
|
||||||
)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid email/username or password." },
|
|
||||||
{ status: 401 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await resetFailedAttempts(dbUser.id)
|
|
||||||
await recordLoginAttempt(
|
|
||||||
dbUser.id,
|
|
||||||
credential,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
|
|
||||||
await createSession(dbUser.id, dbUser.role_name)
|
|
||||||
|
|
||||||
const user = mapDbUserToSessionUser(dbUser)
|
|
||||||
|
|
||||||
return NextResponse.json({ user }, { status: 200 })
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Login error:", error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Authentication service unavailable." },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
import { destroySession } from "@/lib/auth"
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
|
||||||
await destroySession()
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Logout error:", error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Logout failed." },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
import { getSessionUser } from "@/lib/auth"
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const user = await getSessionUser()
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
|
||||||
}
|
|
||||||
return NextResponse.json({ user }, { status: 200 })
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Auth me error:", error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Authentication service unavailable." },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
|
||||||
import { getSessionUser } from "@/lib/auth"
|
|
||||||
import { query } from "@/lib/db"
|
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
|
||||||
|
|
||||||
function getPeriodDateRange(period: string): { start: Date; end: Date } {
|
|
||||||
const end = new Date()
|
|
||||||
let start: Date
|
|
||||||
switch (period) {
|
|
||||||
case "7days":
|
|
||||||
start = new Date(end); start.setDate(start.getDate() - 7); break
|
|
||||||
case "30days":
|
|
||||||
start = new Date(end); start.setDate(start.getDate() - 30); break
|
|
||||||
case "12months":
|
|
||||||
start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
|
|
||||||
case "6months":
|
|
||||||
default:
|
|
||||||
start = new Date(end); start.setMonth(start.getMonth() - 6); break
|
|
||||||
}
|
|
||||||
return { start, end }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } {
|
|
||||||
const end = new Date(currentStart)
|
|
||||||
const diff = end.getTime() - currentStart.getTime()
|
|
||||||
const start = new Date(end.getTime() - diff)
|
|
||||||
return { start, end }
|
|
||||||
}
|
|
||||||
|
|
||||||
const periodLabels: Record<string, string> = {
|
|
||||||
"7days": "Last 7 days",
|
|
||||||
"30days": "Last 30 days",
|
|
||||||
"6months": "Last 6 months",
|
|
||||||
"12months": "Last 12 months",
|
|
||||||
}
|
|
||||||
|
|
||||||
function stageToStatus(name: string): string {
|
|
||||||
switch (name) {
|
|
||||||
case "New": return "open"
|
|
||||||
case "Contacted": return "contacted"
|
|
||||||
case "Qualified":
|
|
||||||
case "Interested":
|
|
||||||
case "Demo Scheduled":
|
|
||||||
case "Negotiation": return "pending"
|
|
||||||
case "Closed Won": return "closed"
|
|
||||||
case "Closed Lost": return "ignored"
|
|
||||||
default: return "open"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmin?: boolean) {
|
|
||||||
const result = await query(
|
|
||||||
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
|
||||||
l.notes, l.assigned_to, l.score,
|
|
||||||
ls.name AS stage_name,
|
|
||||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
|
||||||
FROM leads l
|
|
||||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
|
||||||
LEFT JOIN users u ON u.id = l.assigned_to
|
|
||||||
WHERE l.deleted_at IS NULL
|
|
||||||
AND l.created_at >= $1 AND l.created_at <= $2
|
|
||||||
${isAdmin ? "" : "AND l.assigned_to = $3"}
|
|
||||||
ORDER BY l.created_at DESC`,
|
|
||||||
isAdmin
|
|
||||||
? [start.toISOString(), end.toISOString()]
|
|
||||||
: [start.toISOString(), end.toISOString(), userId]
|
|
||||||
)
|
|
||||||
return result.rows.map((r: any) => ({
|
|
||||||
...r,
|
|
||||||
status: stageToStatus(r.stage_name),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function countStatuses(leads: any[]) {
|
|
||||||
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
|
||||||
leads.forEach((l: any) => {
|
|
||||||
const s = l.status as keyof typeof counts
|
|
||||||
if (s in counts) counts[s]++
|
|
||||||
})
|
|
||||||
return counts
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMonthlyBreakdown(leads: any[], period: string) {
|
|
||||||
const { start, end } = getPeriodDateRange(period)
|
|
||||||
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
|
|
||||||
const current = new Date(start)
|
|
||||||
const isMonthly = period === "6months" || period === "12months"
|
|
||||||
|
|
||||||
while (current <= end) {
|
|
||||||
const label = isMonthly
|
|
||||||
? current.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
|
||||||
: current.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
||||||
|
|
||||||
const ps = new Date(current)
|
|
||||||
const pe = isMonthly
|
|
||||||
? new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59)
|
|
||||||
: (() => { const d = new Date(current); d.setHours(23, 59, 59, 999); return d })()
|
|
||||||
|
|
||||||
const inPeriod = leads.filter((l: any) => {
|
|
||||||
const d = new Date(l.created_at)
|
|
||||||
return d >= ps && d <= pe
|
|
||||||
})
|
|
||||||
|
|
||||||
const counts = countStatuses(inPeriod)
|
|
||||||
result.push({ label, total: inPeriod.length, ...counts })
|
|
||||||
|
|
||||||
if (isMonthly) current.setMonth(current.getMonth() + 1)
|
|
||||||
else current.setDate(current.getDate() + 1)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
|
|
||||||
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
|
|
||||||
const pct = Math.round(((current - previous) / previous) * 100)
|
|
||||||
return { pct: Math.abs(pct), up: pct >= 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const user = await getSessionUser()
|
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
|
||||||
|
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const period = searchParams.get("period") || "6months"
|
|
||||||
const yearParam = searchParams.get("year")
|
|
||||||
let start: Date, end: Date, prevRange: { start: Date; end: Date }
|
|
||||||
if (yearParam) {
|
|
||||||
const y = parseInt(yearParam)
|
|
||||||
start = new Date(y, 0, 1)
|
|
||||||
end = new Date(y, 11, 31, 23, 59, 59)
|
|
||||||
prevRange = { start: new Date(y - 1, 0, 1), end: new Date(y - 1, 11, 31, 23, 59, 59) }
|
|
||||||
} else {
|
|
||||||
const r = getPeriodDateRange(period)
|
|
||||||
start = r.start; end = r.end
|
|
||||||
prevRange = getPreviousPeriodRange(period, start)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [currentLeads, prevLeads] = await Promise.all([
|
|
||||||
fetchLeadsInRange(start, end, user.id, isAdmin),
|
|
||||||
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
|
|
||||||
])
|
|
||||||
|
|
||||||
const currentCounts = countStatuses(currentLeads)
|
|
||||||
const prevCounts = countStatuses(prevLeads)
|
|
||||||
|
|
||||||
const totalLeads = currentLeads.length
|
|
||||||
const closedLeads = currentCounts.closed
|
|
||||||
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
|
||||||
|
|
||||||
const mappedLeads = currentLeads.map((r: any) => ({
|
|
||||||
id: r.id,
|
|
||||||
companyName: r.company_name || "",
|
|
||||||
contactName: r.contact_name,
|
|
||||||
email: r.email || "",
|
|
||||||
phone: r.phone || "",
|
|
||||||
source: "",
|
|
||||||
description: r.notes || "",
|
|
||||||
status: r.status,
|
|
||||||
assignedUserId: r.assigned_to,
|
|
||||||
assignedUser: r.assigned_to ? {
|
|
||||||
id: r.user_id,
|
|
||||||
name: `${r.first_name} ${r.last_name}`,
|
|
||||||
email: r.user_email,
|
|
||||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
|
||||||
} : null,
|
|
||||||
createdAt: r.created_at,
|
|
||||||
updatedAt: r.updated_at,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period)
|
|
||||||
|
|
||||||
const trends = {
|
|
||||||
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
|
||||||
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
|
|
||||||
openLeads: computeTrend(currentCounts.open, prevCounts.open),
|
|
||||||
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
|
|
||||||
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
|
|
||||||
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
|
|
||||||
conversionRate: computeTrend(conversionRate,
|
|
||||||
prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
totalLeads,
|
|
||||||
openLeads: currentCounts.open,
|
|
||||||
contactedLeads: currentCounts.contacted,
|
|
||||||
pendingLeads: currentCounts.pending,
|
|
||||||
closedLeads,
|
|
||||||
ignoredLeads: currentCounts.ignored,
|
|
||||||
conversionRate,
|
|
||||||
monthlyBreakdown,
|
|
||||||
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
|
||||||
trends,
|
|
||||||
recentLeads: mappedLeads.slice(0, 10),
|
|
||||||
statusDistribution: [
|
|
||||||
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
|
||||||
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
|
||||||
{ name: "Pending", value: currentCounts.pending, color: "#8b5cf6" },
|
|
||||||
{ name: "Closed", value: currentCounts.closed, color: "#10b981" },
|
|
||||||
{ name: "Ignored", value: currentCounts.ignored, color: "#6B7280" },
|
|
||||||
],
|
|
||||||
periodLabel: periodLabels[period] ?? "Selected period",
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(stats)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Dashboard API error:", error)
|
|
||||||
return NextResponse.json({ error: "Failed to load dashboard stats" }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from "next/navigation"
|
|
||||||
|
|
||||||
export default function RootPage() {
|
|
||||||
redirect("/dashboard")
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect, Fragment } from "react"
|
|
||||||
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react"
|
|
||||||
|
|
||||||
function linkifyText(text: string) {
|
|
||||||
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
|
||||||
const parts = text.split(urlRegex)
|
|
||||||
return parts.map((part, i) => {
|
|
||||||
if (part.startsWith("http://") || part.startsWith("https://")) {
|
|
||||||
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#1BB0CE] hover:text-[#1BB0CE]/80">{part}</a>
|
|
||||||
}
|
|
||||||
return <Fragment key={i}>{part}</Fragment>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatMessage {
|
|
||||||
role: "user" | "assistant"
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AIChat() {
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
||||||
const [input, setInput] = useState("")
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState("")
|
|
||||||
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null)
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`${AI_API}/ai/jobs`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data.jobs?.length) {
|
|
||||||
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
|
|
||||||
setMessages([
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => `• ${n}`).join("\n")}\n\nWhat would you like to know?`,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
} else {
|
|
||||||
setMessages([
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: "Hi! I'm your Sales AI Assistant. Ask me anything about sales strategies and prospect targeting.",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setMessages([
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: "Hi! I'm your Sales AI Assistant. Ask me anything about sales strategies and prospect targeting.",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
||||||
}, [messages])
|
|
||||||
|
|
||||||
const AI_API = process.env.NEXT_PUBLIC_AI_URL || "http://127.0.0.1:3001"
|
|
||||||
|
|
||||||
const checkOllama = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${AI_API}/health`)
|
|
||||||
const data = await res.json()
|
|
||||||
setOllamaStatus(data.status === "ok")
|
|
||||||
} catch {
|
|
||||||
setOllamaStatus(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { checkOllama() }, [])
|
|
||||||
|
|
||||||
const sendMessage = async () => {
|
|
||||||
const msg = input.trim()
|
|
||||||
if (!msg || loading) return
|
|
||||||
|
|
||||||
setInput("")
|
|
||||||
setError("")
|
|
||||||
setMessages((prev) => [...prev, { role: "user", content: msg }])
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${AI_API}/ai/chat`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ message: msg }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
throw new Error(data.error || `Error ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
setMessages((prev) => [...prev, { role: "assistant", content: data.response }])
|
|
||||||
} catch (err) {
|
|
||||||
const errMsg = err instanceof Error ? err.message : "AI service unavailable"
|
|
||||||
setError(errMsg)
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ role: "assistant", content: `⚠️ Error: ${errMsg}. Make sure Ollama is running with the model loaded.` },
|
|
||||||
])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
sendMessage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
{ollamaStatus === false && (
|
|
||||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs">
|
|
||||||
<AlertCircle className="h-3.5 w-3.5 flex-none" />
|
|
||||||
<span className="flex-1">Ollama not responding. Start it with <code className="bg-amber-500/20 px-1 rounded">ollama serve</code></span>
|
|
||||||
<button type="button" onClick={checkOllama} className="hover:text-amber-300">
|
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
|
|
||||||
{messages.map((msg, i) => (
|
|
||||||
<div key={i} className={`flex gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
|
||||||
{msg.role === "assistant" && (
|
|
||||||
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
|
|
||||||
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={`max-w-[75%] rounded-lg px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
|
|
||||||
msg.role === "user"
|
|
||||||
? "bg-[#1BB0CE] text-white"
|
|
||||||
: "bg-[#1a1a24] text-[#c8c8d0] border border-[#2a2a35]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{linkifyText(msg.content)}
|
|
||||||
</div>
|
|
||||||
{msg.role === "user" && (
|
|
||||||
<div className="h-8 w-8 rounded-full bg-[#1BB0CE] flex items-center justify-center flex-none">
|
|
||||||
<User className="h-4 w-4 text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{loading && (
|
|
||||||
<div className="flex gap-3 justify-start">
|
|
||||||
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
|
|
||||||
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
|
||||||
</div>
|
|
||||||
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-[#2a2a35] p-4">
|
|
||||||
{error && (
|
|
||||||
<div className="mb-2 text-xs text-red-400 flex items-center gap-1.5">
|
|
||||||
<AlertCircle className="h-3 w-3" />
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<textarea
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Ask for sales tips..."
|
|
||||||
rows={1}
|
|
||||||
className="flex-1 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] placeholder-[#6a6a75] resize-none outline-none focus:border-[#1BB0CE]/50"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={sendMessage}
|
|
||||||
disabled={loading || !input.trim()}
|
|
||||||
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { Briefcase, ChevronDown, Loader2 } from "lucide-react"
|
|
||||||
|
|
||||||
interface Job {
|
|
||||||
job_title: string
|
|
||||||
keywords: string[]
|
|
||||||
industry: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JobSelectorProps {
|
|
||||||
onSelect: (job: Job | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JobSelector({ onSelect }: JobSelectorProps) {
|
|
||||||
const [jobs, setJobs] = useState<Job[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [selected, setSelected] = useState<Job | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/ai/jobs")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => setJobs(data.jobs || []))
|
|
||||||
.catch(() => setJobs([]))
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSelect = (job: Job) => {
|
|
||||||
setSelected(job)
|
|
||||||
setOpen(false)
|
|
||||||
onSelect(job)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
className="w-full flex items-center gap-2 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] hover:border-[#1BB0CE]/50 transition-colors"
|
|
||||||
>
|
|
||||||
<Briefcase className="h-4 w-4 text-[#1BB0CE] flex-none" />
|
|
||||||
<span className="flex-1 text-left truncate">
|
|
||||||
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
|
|
||||||
</span>
|
|
||||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ChevronDown className="h-3.5 w-3.5 text-[#6a6a75]" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#15151e] border border-[#2a2a35] rounded-lg shadow-xl max-h-60 overflow-y-auto">
|
|
||||||
{jobs.map((job, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSelect(job)}
|
|
||||||
className="w-full text-left px-3 py-2.5 text-sm text-[#c8c8d0] hover:bg-[#1a1a24] hover:text-[#e8e8ef] transition-colors border-b border-[#1a1a24] last:border-0"
|
|
||||||
>
|
|
||||||
<div className="font-medium">{job.job_title}</div>
|
|
||||||
<div className="text-xs text-[#6a6a75] mt-0.5">{job.industry} — {job.description}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{jobs.length === 0 && !loading && (
|
|
||||||
<div className="px-3 py-4 text-xs text-[#6a6a75] text-center">No job categories loaded</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
export function StatCardSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="col-span-full flex flex-col items-center justify-center py-20">
|
|
||||||
<p className="text-[#CC0000] dark:text-[#FF1111] text-5xl font-['Bangers',cursive] animate-pulse">
|
|
||||||
THWIP!
|
|
||||||
</p>
|
|
||||||
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm mt-3">
|
|
||||||
Loading your data...
|
|
||||||
</p>
|
|
||||||
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
|
|
||||||
<div className="w-full h-full rounded-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] animate-[loading_1.5s_ease-in-out_infinite]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { usePathname } from "next/navigation"
|
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
|
||||||
import { Sidebar } from "./sidebar"
|
|
||||||
import { Topbar } from "./topbar"
|
|
||||||
import { SystemMonitor } from "./system-monitor"
|
|
||||||
|
|
||||||
interface AppShellProps {
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
|
||||||
const pathname = usePathname()
|
|
||||||
|
|
||||||
// Close mobile sidebar on route change
|
|
||||||
useEffect(() => {
|
|
||||||
setMobileOpen(false)
|
|
||||||
}, [pathname])
|
|
||||||
|
|
||||||
// Persist sidebar state
|
|
||||||
useEffect(() => {
|
|
||||||
const saved = localStorage.getItem("sidebar-collapsed")
|
|
||||||
if (saved) setSidebarCollapsed(saved === "true")
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
|
||||||
const next = !sidebarCollapsed
|
|
||||||
setSidebarCollapsed(next)
|
|
||||||
localStorage.setItem("sidebar-collapsed", String(next))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
|
|
||||||
style={{
|
|
||||||
backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
|
|
||||||
backgroundSize: "28px 28px, 14px 14px",
|
|
||||||
backgroundPosition: "0 0, 7px 7px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Spider-Man top gradient bar */}
|
|
||||||
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#e62020] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF6666] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" style={{background: "linear-gradient(90deg, #e62020, #FFFFFF, #0033CC)"}} />
|
|
||||||
|
|
||||||
{/* Corner spider webs */}
|
|
||||||
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-30 dark:opacity-30" style={{opacity: 0.3}}>
|
|
||||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" style={{stroke: "#e62020", strokeWidth: 1.5}}>
|
|
||||||
<line x1="0" y1="0" x2="200" y2="0" />
|
|
||||||
<line x1="0" y1="0" x2="200" y2="60" />
|
|
||||||
<line x1="0" y1="0" x2="200" y2="120" />
|
|
||||||
<line x1="0" y1="0" x2="200" y2="200" />
|
|
||||||
<line x1="0" y1="0" x2="120" y2="200" />
|
|
||||||
<line x1="0" y1="0" x2="60" y2="200" />
|
|
||||||
<line x1="0" y1="0" x2="0" y2="200" />
|
|
||||||
<path d="M0,30 Q30,30 30,0" fill="none" />
|
|
||||||
<path d="M0,60 Q60,60 60,0" fill="none" />
|
|
||||||
<path d="M0,90 Q90,90 90,0" fill="none" />
|
|
||||||
<path d="M0,120 Q120,120 120,0" fill="none" />
|
|
||||||
<path d="M0,150 Q150,150 150,0" fill="none" />
|
|
||||||
<path d="M0,200 Q200,200 200,0" fill="none" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-30 dark:opacity-30" style={{opacity: 0.3}}>
|
|
||||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)", stroke: "#e62020", strokeWidth: 1.5 }}>
|
|
||||||
<line x1="0" y1="0" x2="200" y2="0" />
|
|
||||||
<line x1="0" y1="0" x2="200" y2="60" />
|
|
||||||
<line x1="0" y1="0" x2="200" y2="120" />
|
|
||||||
<line x1="0" y1="0" x2="200" y2="200" />
|
|
||||||
<line x1="0" y1="0" x2="120" y2="200" />
|
|
||||||
<line x1="0" y1="0" x2="60" y2="200" />
|
|
||||||
<line x1="0" y1="0" x2="0" y2="200" />
|
|
||||||
<path d="M0,30 Q30,30 30,0" fill="none" />
|
|
||||||
<path d="M0,60 Q60,60 60,0" fill="none" />
|
|
||||||
<path d="M0,90 Q90,90 90,0" fill="none" />
|
|
||||||
<path d="M0,120 Q120,120 120,0" fill="none" />
|
|
||||||
<path d="M0,150 Q150,150 150,0" fill="none" />
|
|
||||||
<path d="M0,200 Q200,200 200,0" fill="none" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SystemMonitor />
|
|
||||||
<Sidebar
|
|
||||||
collapsed={sidebarCollapsed}
|
|
||||||
onToggle={toggleSidebar}
|
|
||||||
mobileOpen={mobileOpen}
|
|
||||||
onMobileClose={() => setMobileOpen(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={cn("transition-all duration-300", sidebarCollapsed ? "lg:ml-16" : "lg:ml-64")}>
|
|
||||||
<Topbar onMenuClick={() => setMobileOpen(true)} />
|
|
||||||
|
|
||||||
<main className="flex-1 p-4 lg:p-6">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={pathname}
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -8 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function cn(...classes: (string | boolean | undefined | null)[]) {
|
|
||||||
return classes.filter(Boolean).join(" ")
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
|
|
||||||
const RAM_LIMIT_MB = 8192
|
|
||||||
const CPU_LIMIT_PCT = 400 // 4 cores * 100%
|
|
||||||
|
|
||||||
export function SystemMonitor() {
|
|
||||||
const [rssMB, setRssMB] = useState(0)
|
|
||||||
const [cpuPct, setCpuPct] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchStats = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/system/monitor")
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
setRssMB(data.rssMB)
|
|
||||||
setCpuPct(data.cpuPct)
|
|
||||||
} catch {
|
|
||||||
console.warn("Failed to fetch system stats")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchStats()
|
|
||||||
const interval = setInterval(fetchStats, 3000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const ramOver = rssMB > RAM_LIMIT_MB
|
|
||||||
const cpuOver = cpuPct > CPU_LIMIT_PCT
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed top-0 left-0 z-[9999] flex items-center gap-3 px-3 py-1 text-[11px] font-mono bg-black/80 rounded-br-lg select-none">
|
|
||||||
<span className={ramOver ? "text-red-400" : "text-green-400"}>
|
|
||||||
RAM: {rssMB}MB / 8192MB
|
|
||||||
</span>
|
|
||||||
<span className={cpuOver ? "text-red-400" : "text-green-400"}>
|
|
||||||
CPU: {cpuPct}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
|
|
||||||
|
|
||||||
export async function chatWithAI(message: string, jwtToken: string) {
|
|
||||||
const url = `${AI_SERVICE}/ai/chat`
|
|
||||||
const body = JSON.stringify({ message })
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwtToken}` },
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
|
|
||||||
const text = await res.text()
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`AI error ${res.status}: ${text.substring(0, 200)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(text)
|
|
||||||
return data.response || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchJobs() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${AI_SERVICE}/ai/jobs`)
|
|
||||||
if (!res.ok) return []
|
|
||||||
const data = await res.json()
|
|
||||||
return data.jobs || []
|
|
||||||
} catch {
|
|
||||||
console.warn("Failed to fetch AI jobs")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkAiServiceStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${AI_SERVICE}/health`)
|
|
||||||
if (!res.ok) return false
|
|
||||||
const data = await res.json()
|
|
||||||
return data.status === "ok"
|
|
||||||
} catch {
|
|
||||||
console.warn("Failed to check AI service status")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
import { SignJWT, jwtVerify } from "jose";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
const RAW_SECRET = process.env.JWT_SECRET;
|
|
||||||
if (!RAW_SECRET) {
|
|
||||||
throw new Error("JWT_SECRET environment variable is required");
|
|
||||||
}
|
|
||||||
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
|
|
||||||
|
|
||||||
const SESSION_COOKIE = "session";
|
|
||||||
const MAX_FAILED_ATTEMPTS = 5;
|
|
||||||
const LOCKOUT_DURATION_MINUTES = 15;
|
|
||||||
|
|
||||||
export interface SessionUser {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
role: string;
|
|
||||||
avatar: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
|
||||||
return bcrypt.hash(password, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function comparePassword(
|
|
||||||
password: string,
|
|
||||||
hash: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
return bcrypt.compare(password, hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signToken(payload: { userId: string; role: string }) {
|
|
||||||
return new SignJWT(payload)
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime("24h")
|
|
||||||
.setIssuedAt()
|
|
||||||
.sign(JWT_SECRET);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyToken(token: string) {
|
|
||||||
try {
|
|
||||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
|
||||||
return payload as { userId: string; role: string };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserByEmail(email: string) {
|
|
||||||
const result = await query(
|
|
||||||
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
|
||||||
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
|
|
||||||
u.password_change_required, u.avatar_url,
|
|
||||||
r.name AS role_name
|
|
||||||
FROM users u
|
|
||||||
JOIN user_roles ur ON ur.user_id = u.id
|
|
||||||
JOIN roles r ON r.id = ur.role_id
|
|
||||||
WHERE u.email = $1 AND u.deleted_at IS NULL
|
|
||||||
LIMIT 1`,
|
|
||||||
[email.toLowerCase().trim()],
|
|
||||||
);
|
|
||||||
return result.rows[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserByUsername(username: string) {
|
|
||||||
const result = await query(
|
|
||||||
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
|
||||||
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
|
|
||||||
u.password_change_required, u.avatar_url,
|
|
||||||
r.name AS role_name
|
|
||||||
FROM users u
|
|
||||||
JOIN user_roles ur ON ur.user_id = u.id
|
|
||||||
JOIN roles r ON r.id = ur.role_id
|
|
||||||
WHERE u.username = $1 AND u.deleted_at IS NULL
|
|
||||||
LIMIT 1`,
|
|
||||||
[username.toLowerCase().trim()],
|
|
||||||
);
|
|
||||||
return result.rows[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserById(id: string) {
|
|
||||||
const result = await query(
|
|
||||||
` SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
|
||||||
u.is_active, u.avatar_url,
|
|
||||||
r.name AS role_name
|
|
||||||
FROM users u
|
|
||||||
JOIN user_roles ur ON ur.user_id = u.id
|
|
||||||
JOIN roles r ON r.id = ur.role_id
|
|
||||||
WHERE u.id = $1 AND u.deleted_at IS NULL
|
|
||||||
LIMIT 1`,
|
|
||||||
[id],
|
|
||||||
);
|
|
||||||
return result.rows[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recordLoginAttempt(
|
|
||||||
userId: string | null,
|
|
||||||
usernameAttempted: string,
|
|
||||||
ipAddress: string,
|
|
||||||
userAgent: string | null,
|
|
||||||
wasSuccessful: boolean,
|
|
||||||
failureReason?: string,
|
|
||||||
) {
|
|
||||||
await query(
|
|
||||||
`INSERT INTO login_attempts (user_id, username_attempted, ip_address, user_agent, was_successful, failure_reason)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
||||||
[
|
|
||||||
userId,
|
|
||||||
usernameAttempted,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
wasSuccessful,
|
|
||||||
failureReason || null,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function incrementFailedAttempts(userId: string) {
|
|
||||||
await query(
|
|
||||||
`UPDATE users
|
|
||||||
SET failed_login_attempts = failed_login_attempts + 1,
|
|
||||||
locked_until = CASE
|
|
||||||
WHEN failed_login_attempts + 1 >= $2
|
|
||||||
THEN NOW() + (INTERVAL '1 minute' * $3)
|
|
||||||
ELSE locked_until
|
|
||||||
END
|
|
||||||
WHERE id = $1`,
|
|
||||||
[userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetFailedAttempts(userId: string) {
|
|
||||||
await query(
|
|
||||||
`UPDATE users
|
|
||||||
SET failed_login_attempts = 0,
|
|
||||||
locked_until = NULL,
|
|
||||||
last_login_at = NOW()
|
|
||||||
WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function isAccountLocked(user: {
|
|
||||||
is_locked: boolean;
|
|
||||||
locked_until: Date | null;
|
|
||||||
}): Promise<{ locked: boolean; reason?: string }> {
|
|
||||||
if (user.is_locked) {
|
|
||||||
return {
|
|
||||||
locked: true,
|
|
||||||
reason: "Account has been locked by an administrator.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
|
||||||
const minutes = Math.ceil(
|
|
||||||
(new Date(user.locked_until).getTime() - Date.now()) / 60000,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
locked: true,
|
|
||||||
reason: `Account is temporarily locked due to too many failed attempts. Try again in ${minutes} minute(s).`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { locked: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapDbUserToSessionUser(
|
|
||||||
dbUser: Record<string, unknown>,
|
|
||||||
): SessionUser {
|
|
||||||
const roleName = dbUser.role_name as string;
|
|
||||||
|
|
||||||
const roleMapping: Record<string, string> = {
|
|
||||||
SUPER_ADMIN: "super_admin",
|
|
||||||
ADMIN: "admin",
|
|
||||||
SALES_USER: "sales",
|
|
||||||
DEVELOPER: "dev",
|
|
||||||
};
|
|
||||||
|
|
||||||
const avatarUrl = dbUser.avatar_url as string | null
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: dbUser.id as string,
|
|
||||||
username: dbUser.username as string,
|
|
||||||
email: dbUser.email as string,
|
|
||||||
firstName: dbUser.first_name as string,
|
|
||||||
lastName: dbUser.last_name as string,
|
|
||||||
role: roleMapping[roleName] || roleName.toLowerCase(),
|
|
||||||
avatar: avatarUrl || (() => { const f = ((dbUser.first_name as string)?.[0] || '').toUpperCase(); const l = ((dbUser.last_name as string)?.[0] || '').toUpperCase(); return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><rect width="128" height="128" fill="#1d4ed8"/><text x="64" y="80" font-size="48" fill="white" text-anchor="middle" font-family="Arial,Helvetica,sans-serif">${f}${l}</text></svg>`)}` })(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSession(userId: string, role: string) {
|
|
||||||
const token = await signToken({ userId, role });
|
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.set(SESSION_COOKIE, token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "strict",
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24, // 24 hours
|
|
||||||
});
|
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function destroySession() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.set(SESSION_COOKIE, "", {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "strict",
|
|
||||||
path: "/",
|
|
||||||
maxAge: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
|
||||||
try {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
|
||||||
if (!token) return null;
|
|
||||||
|
|
||||||
const payload = await verifyToken(token);
|
|
||||||
if (!payload) return null;
|
|
||||||
|
|
||||||
const dbUser = await getUserById(payload.userId);
|
|
||||||
if (!dbUser) return null;
|
|
||||||
|
|
||||||
return mapDbUserToSessionUser(dbUser);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
function sanitizeName(name: string): string {
|
|
||||||
return name.replace(/[^a-zA-Z0-9\s\-'.-]/g, "").slice(0, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getInitials(name: string): string {
|
|
||||||
return sanitizeName(name)
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join("")
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function avatarSvgUrl(name: string, bg = "1d4ed8", size = 128): string {
|
|
||||||
const initials = getInitials(name)
|
|
||||||
const fontSize = Math.round(size * 0.375)
|
|
||||||
const y = Math.round(size * 0.625)
|
|
||||||
return `data:image/svg+xml,${encodeURIComponent(
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" fill="#${bg}"/><text x="${Math.round(size / 2)}" y="${y}" font-size="${fontSize}" fill="white" text-anchor="middle" font-family="Arial,Helvetica,sans-serif">${initials}</text></svg>`
|
|
||||||
)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAvatarUrl(name: string, avatarUrl: string | null | undefined, bg = "1d4ed8", size = 128): string {
|
|
||||||
return avatarUrl || avatarSvgUrl(name, bg, size)
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
export const LEAD_STATUSES = {
|
|
||||||
open: { label: "Open", color: "bg-blue-500" },
|
|
||||||
contacted: { label: "Contacted", color: "bg-amber-500" },
|
|
||||||
pending: { label: "Pending", color: "bg-purple-500" },
|
|
||||||
closed: { label: "Closed", color: "bg-emerald-500" },
|
|
||||||
ignored: { label: "Ignored", color: "bg-zinc-500" },
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const LEAD_SOURCES = [
|
|
||||||
"Website",
|
|
||||||
"Referral",
|
|
||||||
"LinkedIn",
|
|
||||||
"Cold Call",
|
|
||||||
"Email",
|
|
||||||
"Google",
|
|
||||||
"Social Media",
|
|
||||||
"Other",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export const ITEMS_PER_PAGE = 10
|
|
||||||
|
|
||||||
export const COMPANY_NAME = "Coast IT"
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { Lead } from "@/types"
|
|
||||||
|
|
||||||
export function getPeriodDateRange(period: string): { start: Date; end: Date } {
|
|
||||||
const end = new Date()
|
|
||||||
const start = new Date()
|
|
||||||
|
|
||||||
switch (period) {
|
|
||||||
case "7days":
|
|
||||||
start.setDate(start.getDate() - 7)
|
|
||||||
break
|
|
||||||
case "30days":
|
|
||||||
start.setDate(start.getDate() - 30)
|
|
||||||
break
|
|
||||||
case "6months":
|
|
||||||
start.setMonth(start.getMonth() - 6)
|
|
||||||
break
|
|
||||||
case "12months":
|
|
||||||
start.setMonth(start.getMonth() - 12)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
start.setMonth(start.getMonth() - 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { start, end }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterLeadsByPeriod(leads: Lead[], period: string): Lead[] {
|
|
||||||
const { start, end } = getPeriodDateRange(period)
|
|
||||||
return leads.filter((l) => {
|
|
||||||
const d = new Date(l.createdAt)
|
|
||||||
return d >= start && d <= end
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function groupLeadsByInterval(
|
|
||||||
leads: Lead[],
|
|
||||||
period: string
|
|
||||||
): { label: string; leads: number; closed: number }[] {
|
|
||||||
const { start, end } = getPeriodDateRange(period)
|
|
||||||
|
|
||||||
if (period === "7days" || period === "30days") {
|
|
||||||
return groupByDay(leads, start, end)
|
|
||||||
}
|
|
||||||
return groupByMonth(leads, start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupByDay(
|
|
||||||
leads: Lead[],
|
|
||||||
start: Date,
|
|
||||||
end: Date
|
|
||||||
): { label: string; leads: number; closed: number }[] {
|
|
||||||
const result: { label: string; leads: number; closed: number }[] = []
|
|
||||||
const current = new Date(start)
|
|
||||||
|
|
||||||
while (current <= end) {
|
|
||||||
const dateStr = current.toISOString().slice(0, 10)
|
|
||||||
const dayLeads = leads.filter((l) => l.createdAt.startsWith(dateStr))
|
|
||||||
const closedCount = dayLeads.filter((l) => l.status === "closed").length
|
|
||||||
|
|
||||||
result.push({
|
|
||||||
label: current.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
|
||||||
leads: dayLeads.length,
|
|
||||||
closed: closedCount,
|
|
||||||
})
|
|
||||||
|
|
||||||
current.setDate(current.getDate() + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupByMonth(
|
|
||||||
leads: Lead[],
|
|
||||||
start: Date,
|
|
||||||
end: Date
|
|
||||||
): { label: string; leads: number; closed: number }[] {
|
|
||||||
const result: { label: string; leads: number; closed: number }[] = []
|
|
||||||
const current = new Date(start.getFullYear(), start.getMonth(), 1)
|
|
||||||
|
|
||||||
while (current <= end) {
|
|
||||||
const yearMonth = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}`
|
|
||||||
const monthLeads = leads.filter((l) => l.createdAt.startsWith(yearMonth))
|
|
||||||
const closedCount = monthLeads.filter((l) => l.status === "closed").length
|
|
||||||
|
|
||||||
result.push({
|
|
||||||
label: current.toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
|
|
||||||
leads: monthLeads.length,
|
|
||||||
closed: closedCount,
|
|
||||||
})
|
|
||||||
|
|
||||||
current.setMonth(current.getMonth() + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Pool } from "pg"
|
|
||||||
|
|
||||||
const dbUrl = process.env.DATABASE_URL
|
|
||||||
if (!dbUrl) {
|
|
||||||
throw new Error("DATABASE_URL environment variable is required")
|
|
||||||
}
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: dbUrl,
|
|
||||||
max: 20,
|
|
||||||
idleTimeoutMillis: 30000,
|
|
||||||
connectionTimeoutMillis: 5000,
|
|
||||||
ssl: dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1") ? false : { rejectUnauthorized: false },
|
|
||||||
})
|
|
||||||
|
|
||||||
pool.on("error", (err) => {
|
|
||||||
console.error("Unexpected database pool error:", err)
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function query(text: string, params?: unknown[]) {
|
|
||||||
const client = await pool.connect()
|
|
||||||
try {
|
|
||||||
const result = await client.query(text, params)
|
|
||||||
return result
|
|
||||||
} finally {
|
|
||||||
client.release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default pool
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
import type { NextRequest } from "next/server"
|
|
||||||
import { jwtVerify } from "jose"
|
|
||||||
|
|
||||||
const RAW_SECRET = process.env.JWT_SECRET
|
|
||||||
if (!RAW_SECRET) {
|
|
||||||
throw new Error("JWT_SECRET environment variable is required")
|
|
||||||
}
|
|
||||||
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
|
|
||||||
|
|
||||||
const publicRoutes = [
|
|
||||||
"/login",
|
|
||||||
"/api/auth/login",
|
|
||||||
"/api/auth/logout",
|
|
||||||
"/_next/static",
|
|
||||||
"/_next/image",
|
|
||||||
"/favicon.ico",
|
|
||||||
"/logo",
|
|
||||||
"/fonts",
|
|
||||||
]
|
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
|
||||||
const { pathname } = request.nextUrl
|
|
||||||
|
|
||||||
const isPublic = publicRoutes.some(
|
|
||||||
(route) => pathname === route || pathname.startsWith(route + "/") || pathname.startsWith(route)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isPublic) {
|
|
||||||
return NextResponse.next()
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionCookie = request.cookies.get("session")?.value
|
|
||||||
|
|
||||||
if (!sessionCookie) {
|
|
||||||
if (pathname.startsWith("/api/")) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
|
||||||
}
|
|
||||||
const loginUrl = new URL("/login", request.url)
|
|
||||||
loginUrl.searchParams.set("redirect", pathname)
|
|
||||||
return NextResponse.redirect(loginUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await jwtVerify(sessionCookie, JWT_SECRET)
|
|
||||||
return NextResponse.next()
|
|
||||||
} catch {
|
|
||||||
if (pathname.startsWith("/api/")) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
|
||||||
}
|
|
||||||
const loginUrl = new URL("/login", request.url)
|
|
||||||
loginUrl.searchParams.set("redirect", pathname)
|
|
||||||
return NextResponse.redirect(loginUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|logo|fonts).*)"],
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
export type LeadStatus =
|
|
||||||
| "open"
|
|
||||||
| "contacted"
|
|
||||||
| "pending"
|
|
||||||
| "closed"
|
|
||||||
| "ignored";
|
|
||||||
export type UserRole = "super_admin" | "admin" | "sales";
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: 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 interface Note {
|
|
||||||
id: string;
|
|
||||||
leadId: string;
|
|
||||||
userId: string;
|
|
||||||
authorName: string;
|
|
||||||
authorAvatar: string;
|
|
||||||
authorRole: UserRole;
|
|
||||||
note: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardStats {
|
|
||||||
totalLeads: number;
|
|
||||||
openLeads: number;
|
|
||||||
contactedLeads: number;
|
|
||||||
pendingLeads: number;
|
|
||||||
closedLeads: number;
|
|
||||||
ignoredLeads: number;
|
|
||||||
conversionRate: number;
|
|
||||||
monthlyBreakdown: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[];
|
|
||||||
leadsPerMonth: { label: string; leads: number; closed: number }[];
|
|
||||||
recentLeads: Lead[];
|
|
||||||
statusDistribution: { name: string; value: number; color: string }[];
|
|
||||||
periodLabel: string;
|
|
||||||
trends: {
|
|
||||||
totalLeads: { pct: number; up: boolean };
|
|
||||||
openLeads: { pct: number; up: boolean };
|
|
||||||
contactedLeads: { pct: number; up: boolean };
|
|
||||||
pendingLeads: { pct: number; up: boolean };
|
|
||||||
closedLeads: { pct: number; up: boolean };
|
|
||||||
ignoredLeads: { pct: number; up: boolean };
|
|
||||||
conversionRate: { pct: number; up: boolean };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ColumnFilter {
|
|
||||||
id: string;
|
|
||||||
value: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NotificationType =
|
|
||||||
| "lead_created"
|
|
||||||
| "lead_status_changed"
|
|
||||||
| "lead_assigned"
|
|
||||||
| "chat_message"
|
|
||||||
| "note_added";
|
|
||||||
|
|
||||||
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[];
|
|
||||||
}
|
|
||||||
+23
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
// ── CRM AI Server ──────────────────────────────────────────────────
|
||||||
|
// Provides:
|
||||||
|
// - Chat API (POST /ai/chat) — routes user messages to Ollama for sales coaching
|
||||||
|
// - Setup wizard endpoints (GET /setup/status, POST /setup/profile, etc.)
|
||||||
|
// - Combined /status endpoint for splash page health polling
|
||||||
|
// - Configuration routes (GET/POST /ai/instructions, GET /ai/jobs)
|
||||||
|
// - Model pull support (POST /setup/ollama/pull)
|
||||||
|
//
|
||||||
|
// This is a zero-dependency Node.js HTTP server (no Express needed).
|
||||||
|
|
||||||
|
import http from "node:http"
|
||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import { spawn } from "node:child_process"
|
||||||
|
import crypto from "node:crypto"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ROOT = path.resolve(__dirname, "..")
|
||||||
|
|
||||||
|
// ── Load .env.local ──────────────────────────────────────────────
|
||||||
|
// Reads key=value pairs and sets them as process.env so they're
|
||||||
|
// available throughout the server. Ignores comments and blank lines.
|
||||||
|
// Values with matching quotes are unquoted.
|
||||||
|
try {
|
||||||
|
const envPath = path.join(ROOT, ".env.local")
|
||||||
|
const envContent = fs.readFileSync(envPath, "utf-8")
|
||||||
|
for (const line of envContent.split("\n")) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue
|
||||||
|
const eqIdx = trimmed.indexOf("=")
|
||||||
|
if (eqIdx === -1) continue
|
||||||
|
const k = trimmed.substring(0, eqIdx).trim()
|
||||||
|
let v = trimmed.substring(eqIdx + 1).trim()
|
||||||
|
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
|
||||||
|
if (!process.env[k]) process.env[k] = v
|
||||||
|
}
|
||||||
|
console.log("Loaded .env.local")
|
||||||
|
} catch {
|
||||||
|
// .env.local may not exist (first run), which is fine
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config from env ─────────────────────────────────────────────
|
||||||
|
const PORT = parseInt(process.env.AI_PORT || "3001", 10)
|
||||||
|
const HOST = process.env.AI_HOST || "0.0.0.0"
|
||||||
|
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
||||||
|
const MODEL = process.env.AI_MODEL || "llama3.2:3b"
|
||||||
|
const SCRAPER_URL = process.env.SCRAPER_URL || "http://127.0.0.1:3008"
|
||||||
|
const FRONTEND_URL = process.env.FRONTEND_URL || "http://127.0.0.1:3006"
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL
|
||||||
|
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
|
||||||
|
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
|
||||||
|
|
||||||
|
// ── Setup state ──────────────────────────────────────────────────
|
||||||
|
// Tracks the Ollama model pull process so the setup wizard can
|
||||||
|
// poll for download progress.
|
||||||
|
let pullProcess = null
|
||||||
|
let pullProgress = { status: "idle", progress: 0, message: "" }
|
||||||
|
|
||||||
|
// ── Job loading ─────────────────────────────────────────────────
|
||||||
|
// Loads job categories from a JSONL file (one JSON object per line).
|
||||||
|
// Used as context for the AI sales coach chat responses.
|
||||||
|
/** Load job categories from the JSONL file at JOBS_PATH. Returns an array of parsed job objects. */
|
||||||
|
function loadJobs() {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
||||||
|
const jobs = content
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((l) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(l)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
console.log(`Loaded ${jobs.length} job categories`)
|
||||||
|
return jobs
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to load jobs from ${JOBS_PATH}:`, e.message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ai.md management ────────────────────────────────────────────
|
||||||
|
// ai.md is a Markdown file containing system instructions for the AI.
|
||||||
|
// It can be read, written, or appended to via the API.
|
||||||
|
/** Read the full contents of ai.md. Returns empty string if the file doesn't exist yet. */
|
||||||
|
function readInstructions() {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overwrite ai.md with new content. Returns the content that was written. */
|
||||||
|
function writeInstructions(content) {
|
||||||
|
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
|
||||||
|
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) {
|
||||||
|
// Adds a timestamped entry to the ## Improvement Log section of ai.md
|
||||||
|
const current = readInstructions()
|
||||||
|
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
|
||||||
|
const logEntry = `\n- ${timestamp} — ${entry}`
|
||||||
|
const logSection = "\n## Improvement Log"
|
||||||
|
const logIndex = current.indexOf(logSection)
|
||||||
|
if (logIndex !== -1) {
|
||||||
|
const afterLog = current.substring(logIndex + logSection.length)
|
||||||
|
const nextSectionIndex = afterLog.search(/\n## /)
|
||||||
|
if (nextSectionIndex !== -1) {
|
||||||
|
const insertAt = logIndex + logSection.length + nextSectionIndex
|
||||||
|
const updated = current.substring(0, insertAt) + logEntry + "\n" + current.substring(insertAt)
|
||||||
|
writeInstructions(updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
writeInstructions(current + logEntry + "\n")
|
||||||
|
return current + logEntry + "\n"
|
||||||
|
}
|
||||||
|
const updated = current + `\n${logSection}\n${logEntry}\n`
|
||||||
|
writeInstructions(updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat handler ────────────────────────────────────────────────
|
||||||
|
// scrapeFacebook() calls the scraper service (port 3008) to get leads.
|
||||||
|
// handleChat() processes user messages — triggers lead scraping when
|
||||||
|
// the user asks for "leads" or "listings", otherwise routes to Ollama
|
||||||
|
// for AI-powered sales coaching.
|
||||||
|
async function scrapeFacebook() {
|
||||||
|
const profilePath = process.env.FX_PROFILE || ""
|
||||||
|
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
|
||||||
|
try {
|
||||||
|
const body = await new Promise((resolve, reject) => {
|
||||||
|
const parsed = new URL(SCRAPER_URL)
|
||||||
|
let done = false
|
||||||
|
const req = http.request({ hostname: parsed.hostname, port: parsed.port || 3008, path: urlPath, method: "POST", timeout: 60000 }, (res) => {
|
||||||
|
let data = ""
|
||||||
|
res.on("data", (c) => data += c)
|
||||||
|
res.on("end", () => { done = true; resolve(data) })
|
||||||
|
res.on("error", (e) => { if (!done) { done = true; reject(e) } })
|
||||||
|
})
|
||||||
|
req.on("timeout", () => { if (!done) { done = true; req.destroy(); reject(new Error("scraper timeout")) } })
|
||||||
|
req.on("error", (e) => { if (!done) { done = true; reject(e) } })
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
const data = JSON.parse(body)
|
||||||
|
return data
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scrapeFacebook error:", e.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format scraped Facebook leads into a human-readable Markdown string for the AI chat response. */
|
||||||
|
function formatLeads(leads) {
|
||||||
|
if (!leads || leads.length === 0) return "No leads found from the latest scrape."
|
||||||
|
let output = `**${leads.length} leads found:**\n\n`
|
||||||
|
for (let i = 0; i < leads.length; i++) {
|
||||||
|
const l = leads[i]
|
||||||
|
output += `${i + 1}. ${l.title || "No title"}\n`
|
||||||
|
if (l.author) output += ` Author: ${l.author}\n`
|
||||||
|
if (l.date) output += ` Date: ${l.date}\n`
|
||||||
|
if (l.url) output += ` URL: ${l.url}\n`
|
||||||
|
output += "\n"
|
||||||
|
}
|
||||||
|
return output.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChat(userMessage, userId, userRole) {
|
||||||
|
// If the user asks for leads, trigger the scraper
|
||||||
|
const lowerMsg = userMessage.toLowerCase()
|
||||||
|
const triggerWords = ["lists", "listings", "leads", "recent leads", "pull leads", "show me leads", "show listings"]
|
||||||
|
|
||||||
|
if (triggerWords.some(w => lowerMsg.includes(w))) {
|
||||||
|
const result = await scrapeFacebook()
|
||||||
|
if (result && result.success) {
|
||||||
|
return formatLeads(result.leads)
|
||||||
|
}
|
||||||
|
return "Scraper returned no results or encountered an error. Try again later."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, build a system prompt with job context and send to Ollama
|
||||||
|
const jobs = loadedJobs
|
||||||
|
const instructions = readInstructions()
|
||||||
|
|
||||||
|
const jobList = jobs
|
||||||
|
.map((j) => `- ${j.job_title} (${j.industry}): ${j.description}`)
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
const systemPrompt = `You are a Sales AI Assistant for Coast IT CRM. Your role is to help salespeople with tips, strategies, and guidance.
|
||||||
|
|
||||||
|
Available job categories to target:
|
||||||
|
${jobList}
|
||||||
|
|
||||||
|
Current instructions:
|
||||||
|
${instructions}
|
||||||
|
|
||||||
|
Provide concise, actionable sales advice. When asked about a specific job category, give targeted tips on finding and engaging prospects in that field. Keep responses under 300 words unless asked for detail.`
|
||||||
|
|
||||||
|
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: userMessage },
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
options: { temperature: 0.7, num_predict: 1024 },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!ollamaRes.ok) {
|
||||||
|
const text = await ollamaRes.text()
|
||||||
|
throw new Error(`Ollama error (${ollamaRes.status}): ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await ollamaRes.json()
|
||||||
|
const responseText = data.message?.content || ""
|
||||||
|
|
||||||
|
// Persist conversation to PostgreSQL (best-effort — table may not exist yet)
|
||||||
|
try {
|
||||||
|
if (pgPool && userId) {
|
||||||
|
await pgPool.query(
|
||||||
|
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
[crypto.randomUUID(), userId, userRole || "sales", userMessage, responseText]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// table might not exist, ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseText
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PG pool (lazy init) ────────────────────────────────────────
|
||||||
|
// PostgreSQL connection pool for storing conversation history.
|
||||||
|
// Lazy-initialized so the server starts even without a DB.
|
||||||
|
/** Lazy-initialize the PostgreSQL connection pool. Non-blocking — server works without a database. */
|
||||||
|
let pgPool = null
|
||||||
|
async function initPg() {
|
||||||
|
if (!DATABASE_URL) return
|
||||||
|
try {
|
||||||
|
const { default: pg } = await import("pg")
|
||||||
|
pgPool = new pg.Pool({ connectionString: DATABASE_URL, max: 5 })
|
||||||
|
await pgPool.query("SELECT 1")
|
||||||
|
console.log("Connected to PostgreSQL")
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("PostgreSQL unavailable (AI convos won't be saved):", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request router ─────────────────────────────────────────────
|
||||||
|
const loadedJobs = loadJobs()
|
||||||
|
|
||||||
|
/** Write a JSON response with the given HTTP status code. */
|
||||||
|
function sendJSON(res, status, data) {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the JSON body of an incoming HTTP request. */
|
||||||
|
function parseBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = ""
|
||||||
|
req.on("data", (chunk) => (body += chunk))
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body))
|
||||||
|
} catch {
|
||||||
|
reject(new Error("Invalid JSON"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
req.on("error", reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a request URL into its pathname and search params. Falls back to localhost if no Host header is present. */
|
||||||
|
function parseURL(req) {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
||||||
|
return { pathname: url.pathname, searchParams: url.searchParams }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP Server ─────────────────────────────────────────────────
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
// CORS headers — allow the Next.js frontend (port 3006) to call us
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.writeHead(204)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pathname } = parseURL(req)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// GET /splash — loading screen
|
||||||
|
if (req.method === "GET" && pathname === "/splash") {
|
||||||
|
const splashPath = path.join(ROOT, "splash.html")
|
||||||
|
if (fs.existsSync(splashPath)) {
|
||||||
|
const html = fs.readFileSync(splashPath, "utf-8")
|
||||||
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
|
res.end(html)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSON(res, 200, { status: "ok" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /health
|
||||||
|
if (req.method === "GET" && pathname === "/health") {
|
||||||
|
return sendJSON(res, 200, { status: "ok", model: MODEL })
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /status — combined health of all services
|
||||||
|
// Used by the splash page to check if AI, Scraper, and Frontend are ready.
|
||||||
|
// Polls each service internally to avoid cross-origin CORS issues.
|
||||||
|
if (req.method === "GET" && pathname === "/status") {
|
||||||
|
const { default: http } = await import("http")
|
||||||
|
const results = { ai: true }
|
||||||
|
// Check scraper
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const r = http.get(`${SCRAPER_URL}/health`, { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||||
|
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
|
||||||
|
r.on("error", reject)
|
||||||
|
})
|
||||||
|
results.scraper = true
|
||||||
|
} catch { results.scraper = false }
|
||||||
|
// Check frontend
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const r = http.get(FRONTEND_URL, { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||||
|
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
|
||||||
|
r.on("error", reject)
|
||||||
|
})
|
||||||
|
results.frontend = true
|
||||||
|
} catch { results.frontend = false }
|
||||||
|
return sendJSON(res, 200, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setup endpoints ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /setup/status — check environment
|
||||||
|
// Called by the splash page on boot. Returns info about:
|
||||||
|
// - Ollama availability
|
||||||
|
// - Model presence
|
||||||
|
// - Detected browsers with login status
|
||||||
|
// - Whether this is a first run (wizard needed)
|
||||||
|
if (req.method === "GET" && pathname === "/setup/status") {
|
||||||
|
const envExists = fs.existsSync(path.join(ROOT, ".env.local"))
|
||||||
|
|
||||||
|
// Ollama check
|
||||||
|
let ollamaRunning = false
|
||||||
|
try {
|
||||||
|
await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) })
|
||||||
|
ollamaRunning = true
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Model check
|
||||||
|
let modelAvailable = false
|
||||||
|
if (ollamaRunning) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${OLLAMA_URL}/api/show`, {
|
||||||
|
method: "POST", body: JSON.stringify({ name: MODEL }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
modelAvailable = r.ok
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect all browsers via scraper
|
||||||
|
let browsers = { firefox: { path: null }, opera: { path: null }, chrome: { path: null }, edge: { path: null } }
|
||||||
|
let facebookLoggedIn = false
|
||||||
|
let selectedBrowser = process.env.SELECTED_BROWSER || ""
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${SCRAPER_URL}/health`, { signal: AbortSignal.timeout(2000) })
|
||||||
|
const profiles = await (await fetch(`${SCRAPER_URL}/setup/profile`, { signal: AbortSignal.timeout(5000) })).json()
|
||||||
|
for (const [b, p] of Object.entries(profiles)) {
|
||||||
|
if (p) browsers[b] = { path: p }
|
||||||
|
}
|
||||||
|
// Check login for the selected browser first, then try all
|
||||||
|
const detectedList = Object.entries(browsers).filter(([, v]) => v.path)
|
||||||
|
for (const [b, v] of detectedList) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${SCRAPER_URL}/setup/check-login`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ browser: b, profile_path: v.path }),
|
||||||
|
signal: AbortSignal.timeout(20000),
|
||||||
|
})
|
||||||
|
if (r.ok) {
|
||||||
|
const d = await r.json()
|
||||||
|
browsers[b].logged_in = d.logged_in === true
|
||||||
|
if (d.logged_in && !facebookLoggedIn) {
|
||||||
|
facebookLoggedIn = true
|
||||||
|
if (!selectedBrowser) selectedBrowser = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const anyDetected = Object.values(browsers).some(v => v.path)
|
||||||
|
// first_run = any setup step is incomplete
|
||||||
|
const firstRun = !envExists || !ollamaRunning || !anyDetected || !facebookLoggedIn || !modelAvailable
|
||||||
|
|
||||||
|
return sendJSON(res, 200, {
|
||||||
|
first_run: firstRun,
|
||||||
|
env_exists: envExists,
|
||||||
|
ollama_running: ollamaRunning,
|
||||||
|
model_available: modelAvailable,
|
||||||
|
model_name: MODEL,
|
||||||
|
selected_browser: selectedBrowser,
|
||||||
|
browsers,
|
||||||
|
facebook_logged_in: facebookLoggedIn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /setup/profile — save selected browser + path to .env.local
|
||||||
|
// Called by the setup wizard when the user confirms their browser choice.
|
||||||
|
// Writes SELECTED_BROWSER and the matching profile env var to .env.local.
|
||||||
|
if (req.method === "POST" && pathname === "/setup/profile") {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
const browserName = (body.browser || "").trim().toLowerCase()
|
||||||
|
const profilePath = (body.path || "").trim()
|
||||||
|
if (!browserName || !["firefox", "opera", "chrome", "edge"].includes(browserName))
|
||||||
|
return sendJSON(res, 400, { error: "Valid browser required (firefox/opera/chrome/edge)" })
|
||||||
|
if (!profilePath)
|
||||||
|
return sendJSON(res, 400, { error: "Path required" })
|
||||||
|
|
||||||
|
const envKey = browserName === "firefox" ? "FX_PROFILE"
|
||||||
|
: browserName === "opera" ? "OPERA_PROFILE"
|
||||||
|
: browserName === "edge" ? "EDGE_PROFILE"
|
||||||
|
: "CHROME_PROFILE"
|
||||||
|
|
||||||
|
const envPath = path.join(ROOT, ".env.local")
|
||||||
|
let content = ""
|
||||||
|
try { content = fs.readFileSync(envPath, "utf-8") } catch {}
|
||||||
|
let lines = content.split("\n")
|
||||||
|
// Update or add SELECTED_BROWSER
|
||||||
|
let foundSel = false
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].trim().startsWith("SELECTED_BROWSER=")) {
|
||||||
|
lines[i] = `SELECTED_BROWSER=${browserName}`
|
||||||
|
foundSel = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundSel) lines.push(`SELECTED_BROWSER=${browserName}`)
|
||||||
|
// Update or add browser profile
|
||||||
|
let foundProf = false
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].trim().startsWith(`${envKey}=`)) {
|
||||||
|
lines[i] = `${envKey}=${profilePath}`
|
||||||
|
foundProf = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundProf) lines.push(`${envKey}=${profilePath}`)
|
||||||
|
fs.writeFileSync(envPath, lines.join("\n"), "utf-8")
|
||||||
|
process.env.SELECTED_BROWSER = browserName
|
||||||
|
process.env[envKey] = profilePath
|
||||||
|
return sendJSON(res, 200, { success: true, browser: browserName, path: profilePath })
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /setup/check-login — proxy to scraper, accepts browser + profile_path
|
||||||
|
// The splash page calls this (via the AI server) to verify Facebook login status.
|
||||||
|
if (req.method === "POST" && pathname === "/setup/check-login") {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
const browserName = (body.browser || "").trim().toLowerCase() || process.env.SELECTED_BROWSER || ""
|
||||||
|
const profilePath = (body.profile_path || "").trim()
|
||||||
|
|
||||||
|
if (!profilePath) return sendJSON(res, 200, { logged_in: false, reason: "no_profile" })
|
||||||
|
try {
|
||||||
|
const r = await fetch("http://127.0.0.1:3008/setup/check-login", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ browser: browserName, profile_path: profilePath }),
|
||||||
|
signal: AbortSignal.timeout(20000),
|
||||||
|
})
|
||||||
|
if (r.ok) { const d = await r.json(); return sendJSON(res, 200, d) }
|
||||||
|
} catch {}
|
||||||
|
return sendJSON(res, 200, { logged_in: false, reason: "scraper_unavailable" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /setup/ollama/pull — start pulling the model
|
||||||
|
// Spawns "ollama pull" as a child process. The setup wizard polls
|
||||||
|
// the progress endpoint to show a download progress bar.
|
||||||
|
if (req.method === "POST" && pathname === "/setup/ollama/pull") {
|
||||||
|
if (pullProcess) return sendJSON(res, 200, { status: "already_running" })
|
||||||
|
pullProgress = { status: "downloading", progress: 0, message: "Starting..." }
|
||||||
|
const isWin = process.platform === "win32"
|
||||||
|
const cmd = isWin ? "ollama.exe" : "ollama"
|
||||||
|
pullProcess = spawn(cmd, ["pull", MODEL], { stdio: ["ignore", "pipe", "pipe"] })
|
||||||
|
|
||||||
|
pullProcess.stdout.on("data", (data) => {
|
||||||
|
const text = data.toString()
|
||||||
|
pullProgress.message = text.trim()
|
||||||
|
// Extract percentage from patterns like "pulling xxxx... 45%"
|
||||||
|
const m = text.match(/(\d+)%/)
|
||||||
|
if (m) pullProgress.progress = parseInt(m[1], 10)
|
||||||
|
})
|
||||||
|
pullProcess.on("close", (code) => {
|
||||||
|
pullProcess = null
|
||||||
|
pullProgress.status = code === 0 ? "done" : "failed"
|
||||||
|
if (code === 0) pullProgress.progress = 100
|
||||||
|
})
|
||||||
|
return sendJSON(res, 200, { status: "started" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /setup/ollama/pull/progress
|
||||||
|
// Returns current download progress for the setup wizard.
|
||||||
|
if (req.method === "GET" && pathname === "/setup/ollama/pull/progress") {
|
||||||
|
return sendJSON(res, 200, pullProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /ai/jobs — return loaded job categories
|
||||||
|
if (req.method === "GET" && pathname === "/ai/jobs") {
|
||||||
|
return sendJSON(res, 200, { jobs: loadedJobs })
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /ai/instructions — return current ai.md content
|
||||||
|
if (req.method === "GET" && pathname === "/ai/instructions") {
|
||||||
|
const instructions = readInstructions()
|
||||||
|
return sendJSON(res, 200, { success: true, instructions })
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /ai/instructions — update ai.md or append improvement log entry
|
||||||
|
if (req.method === "POST" && pathname === "/ai/instructions") {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
if (body.content) {
|
||||||
|
writeInstructions(body.content)
|
||||||
|
} else if (body.entry) {
|
||||||
|
appendToImprovementLog(body.entry)
|
||||||
|
}
|
||||||
|
return sendJSON(res, 200, {
|
||||||
|
success: true,
|
||||||
|
instructions: readInstructions(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /ai/chat — main AI chat endpoint
|
||||||
|
// Accepts { message, user_id?, user_role? } and returns AI response.
|
||||||
|
// user_role must be "sales", "admin", or "super_admin" if provided.
|
||||||
|
if (req.method === "POST" && pathname === "/ai/chat") {
|
||||||
|
const chunks = []
|
||||||
|
req.on("data", c => chunks.push(c))
|
||||||
|
req.on("end", async () => {
|
||||||
|
try {
|
||||||
|
const rawBody = Buffer.concat(chunks).toString()
|
||||||
|
const body = JSON.parse(rawBody)
|
||||||
|
const { message, user_id, user_role } = body
|
||||||
|
if (!message) {
|
||||||
|
return sendJSON(res, 400, { error: "message is required" })
|
||||||
|
}
|
||||||
|
const validRoles = ["sales", "admin", "super_admin"]
|
||||||
|
if (user_role && !validRoles.includes(user_role)) {
|
||||||
|
return sendJSON(res, 403, { error: "Forbidden" })
|
||||||
|
}
|
||||||
|
const response = await handleChat(message, user_id || "", user_role || "sales")
|
||||||
|
sendJSON(res, 200, { response })
|
||||||
|
} catch (e) {
|
||||||
|
if (!res.headersSent) sendJSON(res, 500, { error: e.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 fallback
|
||||||
|
sendJSON(res, 404, { error: "Not found" })
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Request error:", err)
|
||||||
|
sendJSON(res, 500, { error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Start ───────────────────────────────────────────────────────
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`CRM AI server listening on http://${HOST}:${PORT}`)
|
||||||
|
console.log(` Model: ${MODEL}`)
|
||||||
|
console.log(` Ollama: ${OLLAMA_URL}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
initPg()
|
||||||
@@ -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"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
# ── Python Dependencies for the Facebook Scraper (FastAPI) ───────
|
||||||
|
# Install via: pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Web framework for the REST API (health, setup, scrape endpoints)
|
||||||
|
fastapi>=0.115.0
|
||||||
|
|
||||||
|
# ASGI server for running the FastAPI app
|
||||||
|
uvicorn>=0.34.0
|
||||||
|
|
||||||
|
# Browser automation (launches Firefox, Chrome, Edge, Opera via Playwright)
|
||||||
|
playwright>=1.49.0
|
||||||
|
|
||||||
|
# AI-powered browser agent (fallback when direct browser scraping is flagged)
|
||||||
|
# Uses ChatOllama locally — no API keys needed
|
||||||
|
browser-use>=0.1.0
|
||||||
|
|
||||||
|
# LangChain integration for ChatOllama (provides the LLM for browser-use Agent)
|
||||||
|
langchain-ollama>=0.2.0
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
# CRM AI Sales Assistant — Self-Knowledge
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
You are the CRM AI Sales Assistant for Coast IT CRM.
|
||||||
|
You run on a Node.js backend (port 3001) and use Ollama with a local model (dolphin3-llama3.2:3b).
|
||||||
|
Your purpose is to help salespeople close more deals by finding and engaging leads.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
```
|
||||||
|
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
|
||||||
|
↓
|
||||||
|
PostgreSQL (conversations)
|
||||||
|
|
||||||
|
Python Scraper (:3008) — Facebook scraping via Playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
Three services run concurrently:
|
||||||
|
- **AI Server** (`ai-server/index.mjs`, port 3001) — chat, setup wizard, config endpoints
|
||||||
|
- **Frontend** (Next.js, port 3006) — UI for salespeople
|
||||||
|
- **Scraper** (`browser-use-service/main.py`, port 3008) — Facebook lead discovery
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
- Give sales tips and strategies per job category
|
||||||
|
- Generate cold email and outreach templates
|
||||||
|
- Handle objections with proven rebuttals
|
||||||
|
- Analyse prospect behaviour and suggest next steps
|
||||||
|
- Remember past conversations via PostgreSQL (`ai_conversations` table)
|
||||||
|
- Run Facebook scraper to find real leads asking for services
|
||||||
|
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
|
||||||
|
|
||||||
|
## Facebook Scraper
|
||||||
|
The scraper lives at `browser-use-service/main.py` port 3008.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
|
||||||
|
2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
|
||||||
|
3. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
|
||||||
|
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
|
||||||
|
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
|
||||||
|
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
|
||||||
|
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
|
||||||
|
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
|
||||||
|
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
|
||||||
|
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
|
||||||
|
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
|
||||||
|
6. **Stealth mechanics**:
|
||||||
|
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
|
||||||
|
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
|
||||||
|
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
|
||||||
|
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
|
||||||
|
- Random decoy page visits (e.g., Facebook Groups) between searches
|
||||||
|
- Profile directory is temp-copied and cleaned up after each scrape
|
||||||
|
- Detection signal monitoring (checkpoint, login pages, security challenges)
|
||||||
|
7. **2-pass classification (dead-accurate)**:
|
||||||
|
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
|
||||||
|
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
|
||||||
|
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
|
||||||
|
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
|
||||||
|
|
||||||
|
### Lead Categories
|
||||||
|
Two categories, selectable when starting a scrape:
|
||||||
|
|
||||||
|
**Website Creation:**
|
||||||
|
- Target: people explicitly REQUESTING a website built/designed/created for them
|
||||||
|
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
|
||||||
|
- Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
|
||||||
|
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
|
||||||
|
|
||||||
|
**Tutoring:**
|
||||||
|
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
|
||||||
|
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
|
||||||
|
- Request terms: same as website category — must co-occur with a target keyword
|
||||||
|
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
|
||||||
|
|
||||||
|
### Multi-Language Pipeline (Phase Order)
|
||||||
|
4 South African languages in structured phases:
|
||||||
|
- **Phase 1 (English)**: primary query + supplementary English searches
|
||||||
|
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
|
||||||
|
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
|
||||||
|
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
|
||||||
|
|
||||||
|
### Output Format
|
||||||
|
Each lead returned includes:
|
||||||
|
- `title` — post preview text
|
||||||
|
- `author` — poster's name (may include location in name)
|
||||||
|
- `content` — extracted post text
|
||||||
|
- `url` — direct link to the post
|
||||||
|
- `date` — when posted (filtered within 7 days)
|
||||||
|
- `category` — "website" or "tutor"
|
||||||
|
|
||||||
|
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
|
||||||
|
|
||||||
|
### Configuration via Env Vars
|
||||||
|
- `SELECTED_BROWSER` — `firefox` (default), `chrome`, `opera`, `edge`, or `auto`
|
||||||
|
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
|
||||||
|
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
|
||||||
|
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
|
||||||
|
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
|
||||||
|
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
|
||||||
|
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
|
||||||
|
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
|
||||||
|
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
|
||||||
|
|
||||||
|
## How to Start Scraping
|
||||||
|
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
|
||||||
|
2. Open the frontend at `http://localhost:3006`
|
||||||
|
3. Select a job category (Website Creation or Tutoring)
|
||||||
|
4. Click "Search Facebook" — the scraper runs and returns leads
|
||||||
|
5. Leads are saved in the CRM for follow-up
|
||||||
|
|
||||||
|
## Sales Tips
|
||||||
|
- Cold emails should be under 150 words
|
||||||
|
- Follow up within 48 hours
|
||||||
|
- Personalise every outreach with the prospect's name and company
|
||||||
|
- Use open-ended questions in discovery calls
|
||||||
|
- Always ask for the next step before ending a call
|
||||||
|
- For website leads: mention specific pages or features they requested
|
||||||
|
- For tutoring leads: reference the subject and age group they mentioned
|
||||||
|
|
||||||
|
## Job Targeting
|
||||||
|
- Developers respond best to technical value props
|
||||||
|
- Marketing managers care about ROI and metrics
|
||||||
|
- C-level executives want brevity and business impact
|
||||||
|
- Parents hiring tutors: empathy and qualifications matter most
|
||||||
|
|
||||||
|
## Response Rules
|
||||||
|
- Be direct and actionable — no fluff, no AI disclaimers
|
||||||
|
- Use short paragraphs and bullet points
|
||||||
|
- Never mention being an AI or language model
|
||||||
|
- If you don't know something, say so honestly
|
||||||
|
- Prioritise the user's role: salespeople need speed, admins need control
|
||||||
|
- When asked about scraping, give specific guidance on categories and languages
|
||||||
|
|
||||||
|
## Self-Improvement Protocol
|
||||||
|
1. You notice a gap in your knowledge or a pattern in user questions
|
||||||
|
2. You call `POST /ai/instructions` with:
|
||||||
|
- `entry`: description of the improvement
|
||||||
|
- `content`: optional full replacement of ai.md
|
||||||
|
3. The improvement is logged and loaded into the next system prompt
|
||||||
|
|
||||||
|
## Improvement Log
|
||||||
|
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"job_title":"Website Creation","keywords":["need a website","build my website","create a website for me","website for my business","need someone to build","who can build me","looking for a web developer","need a web designer","i need a website","need help with my website","looking for someone to create","need a site for","want a website for","need ecommerce website","need landing page","website for my small business","need my website redesigned","can someone build me","anyone know a web developer","recommend a web developer","need a new website"],"industry":"Technology","description":"Find people asking for websites, landing pages, or online stores to be built"}
|
||||||
|
{"job_title":"Tutoring","keywords":["need a tutor","looking for a tutor","tutor for my child","need help with homework","private tutor needed","looking for tutoring","need a math tutor","english tutor needed","online tutor for","lessons for my child","help my child with","need someone to tutor","looking for someone to teach","tutoring for my","need help learning","recommend a tutor","anyone know a tutor","need a private tutor","tutoring services for my child","need academic help"],"industry":"Education","description":"Find parents and students asking for tutoring services"}
|
||||||
+18
-3
@@ -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);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
create table if not exists contacts (
|
||||||
|
id uuid default gen_random_uuid() primary key,
|
||||||
|
user_id uuid references auth.users(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
phone text not null,
|
||||||
|
avatar_url text,
|
||||||
|
created_at timestamp default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table contacts enable row level security;
|
||||||
|
|
||||||
|
create policy "Users see own contacts"
|
||||||
|
on contacts for select
|
||||||
|
using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
create policy "Users insert own contacts"
|
||||||
|
on contacts for insert
|
||||||
|
with check (auth.uid() = user_id);
|
||||||
|
|
||||||
|
create policy "Users delete own contacts"
|
||||||
|
on contacts for delete
|
||||||
|
using (auth.uid() = user_id);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);
|
||||||
@@ -0,0 +1,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);
|
||||||
@@ -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.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user