Compare commits
175 Commits
277c719963
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| cd797c5b3c | |||
| 9d9309833b | |||
| 1e332fb50d | |||
| 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 | |||
| fd2d4ff976 |
@@ -0,0 +1 @@
|
||||
rust-ai/target/** linguist-generated=true -diff -merge
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Build & Auto-Repair
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Install Playwright
|
||||
run: npx playwright install chromium firefox
|
||||
|
||||
- name: Build check
|
||||
id: build
|
||||
continue-on-error: true
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run self-healing setup
|
||||
if: steps.build.outcome == 'failure'
|
||||
run: node scripts/setup.mjs --self-heal
|
||||
|
||||
- name: Run code repair agent
|
||||
if: steps.build.outcome == 'failure'
|
||||
env:
|
||||
OLLAMA_HOST: ${{ vars.OLLAMA_HOST || 'http://localhost:11434' }}
|
||||
run: node scripts/code-repair-agent.mjs --ci
|
||||
|
||||
- name: Re-check build after repair
|
||||
id: rebuild
|
||||
continue-on-error: true
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Commit fixes
|
||||
if: steps.build.outcome == 'failure' && steps.rebuild.outcome == 'success'
|
||||
run: |
|
||||
git config user.name "CRM Repair Bot"
|
||||
git config user.email "bot@coastit.co.za"
|
||||
git add -A
|
||||
git diff --cached --quiet || git commit -m "[bot] Auto-repair build errors"
|
||||
git push
|
||||
|
||||
- name: Build failed — report
|
||||
if: steps.rebuild.outcome == 'failure'
|
||||
run: |
|
||||
echo "::error::Build still failing after auto-repair. Manual intervention required."
|
||||
exit 1
|
||||
+24
@@ -34,9 +34,33 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# runtime data
|
||||
data/
|
||||
data/ai/
|
||||
data/ai/jobs.jsonl
|
||||
|
||||
# logs
|
||||
*.log
|
||||
*.out
|
||||
error-log
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# python
|
||||
browser-use-service/venv/
|
||||
browser-use-service/__pycache__/
|
||||
browser-use-service/.env
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.git
|
||||
.git_bak
|
||||
node_modules
|
||||
target
|
||||
# rust build artifacts
|
||||
rust-ai/target/
|
||||
|
||||
# browser-use-service generated files
|
||||
browser-use-service/*.txt
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,14 @@ To learn more about Next.js, take a look at the following resources:
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
## Install
|
||||
- Jose
|
||||
- Node.js
|
||||
- Add emoji pakages
|
||||
- Install postgres for databases
|
||||
- Typescript
|
||||
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
@@ -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,27 @@
|
||||
export type EventType = "call" | "follow_up" | "website_creation"
|
||||
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string
|
||||
userId: string
|
||||
participantId: string | null
|
||||
developerId: string | null
|
||||
leadId: string | null
|
||||
conversationId: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
participantNotes: string | null
|
||||
eventType: EventType
|
||||
startTime: string
|
||||
endTime: string | null
|
||||
durationMinutes: number | null
|
||||
status: EventStatus
|
||||
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
|
||||
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
|
||||
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
|
||||
lead: { id: string; companyName: string; contactName: string } | null
|
||||
clientName: string | null
|
||||
clientEmail: string | null
|
||||
clientPhone: string | null
|
||||
createdAt: string
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
// ── CRM AI Server ──────────────────────────────────────────────────
|
||||
// Provides:
|
||||
// - Chat API (POST /ai/chat) — routes user messages to Ollama for sales coaching
|
||||
// - Setup wizard endpoints (GET /setup/status, POST /setup/profile, etc.)
|
||||
// - Combined /status endpoint for splash page health polling
|
||||
// - Configuration routes (GET/POST /ai/instructions, GET /ai/jobs)
|
||||
// - Model pull support (POST /setup/ollama/pull)
|
||||
//
|
||||
// This is a zero-dependency Node.js HTTP server (no Express needed).
|
||||
|
||||
import http from "node:http"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { spawn } from "node:child_process"
|
||||
import crypto from "node:crypto"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = path.resolve(__dirname, "..")
|
||||
|
||||
// ── Load .env.local ──────────────────────────────────────────────
|
||||
// Reads key=value pairs and sets them as process.env so they're
|
||||
// available throughout the server. Ignores comments and blank lines.
|
||||
// Values with matching quotes are unquoted.
|
||||
try {
|
||||
const envPath = path.join(ROOT, ".env.local")
|
||||
const envContent = fs.readFileSync(envPath, "utf-8")
|
||||
for (const line of envContent.split("\n")) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith("#")) continue
|
||||
const eqIdx = trimmed.indexOf("=")
|
||||
if (eqIdx === -1) continue
|
||||
const k = trimmed.substring(0, eqIdx).trim()
|
||||
let v = trimmed.substring(eqIdx + 1).trim()
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
|
||||
if (!process.env[k]) process.env[k] = v
|
||||
}
|
||||
console.log("Loaded .env.local")
|
||||
} catch {
|
||||
// .env.local may not exist (first run), which is fine
|
||||
}
|
||||
|
||||
// ── Config from env ─────────────────────────────────────────────
|
||||
const PORT = parseInt(process.env.AI_PORT || "3001", 10)
|
||||
const HOST = process.env.AI_HOST || "0.0.0.0"
|
||||
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
||||
const MODEL = process.env.AI_MODEL || "llama3.2:3b"
|
||||
const SCRAPER_URL = process.env.SCRAPER_URL || "http://127.0.0.1:3008"
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || "http://127.0.0.1:3006"
|
||||
const DATABASE_URL = process.env.DATABASE_URL
|
||||
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
|
||||
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
|
||||
|
||||
// ── Setup state ──────────────────────────────────────────────────
|
||||
// Tracks the Ollama model pull process so the setup wizard can
|
||||
// poll for download progress.
|
||||
let pullProcess = null
|
||||
let pullProgress = { status: "idle", progress: 0, message: "" }
|
||||
|
||||
// ── Job loading ─────────────────────────────────────────────────
|
||||
// Loads job categories from a JSONL file (one JSON object per line).
|
||||
// Used as context for the AI sales coach chat responses.
|
||||
function loadJobs() {
|
||||
try {
|
||||
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
||||
const jobs = content
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((l) => {
|
||||
try {
|
||||
return JSON.parse(l)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
console.log(`Loaded ${jobs.length} job categories`)
|
||||
return jobs
|
||||
} catch (e) {
|
||||
console.error(`Failed to load jobs from ${JOBS_PATH}:`, e.message)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// ── ai.md management ────────────────────────────────────────────
|
||||
// ai.md is a Markdown file containing system instructions for the AI.
|
||||
// It can be read, written, or appended to via the API.
|
||||
function readInstructions() {
|
||||
try {
|
||||
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function writeInstructions(content) {
|
||||
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
|
||||
return content
|
||||
}
|
||||
|
||||
function appendToImprovementLog(entry) {
|
||||
// Adds a timestamped entry to the ## Improvement Log section of ai.md
|
||||
const current = readInstructions()
|
||||
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
|
||||
const logEntry = `\n- ${timestamp} — ${entry}`
|
||||
const logSection = "\n## Improvement Log"
|
||||
const logIndex = current.indexOf(logSection)
|
||||
if (logIndex !== -1) {
|
||||
const afterLog = current.substring(logIndex + logSection.length)
|
||||
const nextSectionIndex = afterLog.search(/\n## /)
|
||||
if (nextSectionIndex !== -1) {
|
||||
const insertAt = logIndex + logSection.length + nextSectionIndex
|
||||
const updated = current.substring(0, insertAt) + logEntry + "\n" + current.substring(insertAt)
|
||||
writeInstructions(updated)
|
||||
return updated
|
||||
}
|
||||
writeInstructions(current + logEntry + "\n")
|
||||
return current + logEntry + "\n"
|
||||
}
|
||||
const updated = current + `\n${logSection}\n${logEntry}\n`
|
||||
writeInstructions(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
// ── Chat handler ────────────────────────────────────────────────
|
||||
// scrapeFacebook() calls the scraper service (port 3008) to get leads.
|
||||
// handleChat() processes user messages — triggers lead scraping when
|
||||
// the user asks for "leads" or "listings", otherwise routes to Ollama
|
||||
// for AI-powered sales coaching.
|
||||
async function scrapeFacebook() {
|
||||
const profilePath = process.env.FX_PROFILE || ""
|
||||
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
|
||||
try {
|
||||
const body = await new Promise((resolve, reject) => {
|
||||
const parsed = new URL(SCRAPER_URL)
|
||||
let done = false
|
||||
const req = http.request({ hostname: parsed.hostname, port: parsed.port || 3008, path: urlPath, method: "POST", timeout: 60000 }, (res) => {
|
||||
let data = ""
|
||||
res.on("data", (c) => data += c)
|
||||
res.on("end", () => { done = true; resolve(data) })
|
||||
res.on("error", (e) => { if (!done) { done = true; reject(e) } })
|
||||
})
|
||||
req.on("timeout", () => { if (!done) { done = true; req.destroy(); reject(new Error("scraper timeout")) } })
|
||||
req.on("error", (e) => { if (!done) { done = true; reject(e) } })
|
||||
req.end()
|
||||
})
|
||||
const data = JSON.parse(body)
|
||||
return data
|
||||
} catch (e) {
|
||||
console.error("scrapeFacebook error:", e.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatLeads(leads) {
|
||||
if (!leads || leads.length === 0) return "No leads found from the latest scrape."
|
||||
let output = `**${leads.length} leads found:**\n\n`
|
||||
for (let i = 0; i < leads.length; i++) {
|
||||
const l = leads[i]
|
||||
output += `${i + 1}. ${l.title || "No title"}\n`
|
||||
if (l.author) output += ` Author: ${l.author}\n`
|
||||
if (l.date) output += ` Date: ${l.date}\n`
|
||||
if (l.url) output += ` URL: ${l.url}\n`
|
||||
output += "\n"
|
||||
}
|
||||
return output.trim()
|
||||
}
|
||||
|
||||
async function handleChat(userMessage, userId, userRole) {
|
||||
// If the user asks for leads, trigger the scraper
|
||||
const lowerMsg = userMessage.toLowerCase()
|
||||
const triggerWords = ["lists", "listings", "leads", "recent leads", "pull leads", "show me leads", "show listings"]
|
||||
|
||||
if (triggerWords.some(w => lowerMsg.includes(w))) {
|
||||
const result = await scrapeFacebook()
|
||||
if (result && result.success) {
|
||||
return formatLeads(result.leads)
|
||||
}
|
||||
return "Scraper returned no results or encountered an error. Try again later."
|
||||
}
|
||||
|
||||
// Otherwise, build a system prompt with job context and send to Ollama
|
||||
const jobs = loadedJobs
|
||||
const instructions = readInstructions()
|
||||
|
||||
const jobList = jobs
|
||||
.map((j) => `- ${j.job_title} (${j.industry}): ${j.description}`)
|
||||
.join("\n")
|
||||
|
||||
const systemPrompt = `You are a Sales AI Assistant for Coast IT CRM. Your role is to help salespeople with tips, strategies, and guidance.
|
||||
|
||||
Available job categories to target:
|
||||
${jobList}
|
||||
|
||||
Current instructions:
|
||||
${instructions}
|
||||
|
||||
Provide concise, actionable sales advice. When asked about a specific job category, give targeted tips on finding and engaging prospects in that field. Keep responses under 300 words unless asked for detail.`
|
||||
|
||||
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: AbortSignal.timeout(60000),
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userMessage },
|
||||
],
|
||||
stream: false,
|
||||
options: { temperature: 0.7, num_predict: 1024 },
|
||||
}),
|
||||
})
|
||||
|
||||
if (!ollamaRes.ok) {
|
||||
const text = await ollamaRes.text()
|
||||
throw new Error(`Ollama error (${ollamaRes.status}): ${text}`)
|
||||
}
|
||||
|
||||
const data = await ollamaRes.json()
|
||||
const responseText = data.message?.content || ""
|
||||
|
||||
// Persist conversation to PostgreSQL (best-effort — table may not exist yet)
|
||||
try {
|
||||
if (pgPool && userId) {
|
||||
await pgPool.query(
|
||||
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||
[crypto.randomUUID(), userId, userRole || "sales", userMessage, responseText]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// table might not exist, ignore
|
||||
}
|
||||
|
||||
return responseText
|
||||
}
|
||||
|
||||
// ── PG pool (lazy init) ────────────────────────────────────────
|
||||
// PostgreSQL connection pool for storing conversation history.
|
||||
// Lazy-initialized so the server starts even without a DB.
|
||||
let pgPool = null
|
||||
async function initPg() {
|
||||
if (!DATABASE_URL) return
|
||||
try {
|
||||
const { default: pg } = await import("pg")
|
||||
pgPool = new pg.Pool({ connectionString: DATABASE_URL, max: 5 })
|
||||
await pgPool.query("SELECT 1")
|
||||
console.log("Connected to PostgreSQL")
|
||||
} catch (e) {
|
||||
console.warn("PostgreSQL unavailable (AI convos won't be saved):", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request router ─────────────────────────────────────────────
|
||||
const loadedJobs = loadJobs()
|
||||
|
||||
function sendJSON(res, status, data) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify(data))
|
||||
}
|
||||
|
||||
function parseBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = ""
|
||||
req.on("data", (chunk) => (body += chunk))
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(body))
|
||||
} catch {
|
||||
reject(new Error("Invalid JSON"))
|
||||
}
|
||||
})
|
||||
req.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function parseURL(req) {
|
||||
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
||||
return { pathname: url.pathname, searchParams: url.searchParams }
|
||||
}
|
||||
|
||||
// ── HTTP Server ─────────────────────────────────────────────────
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// CORS headers — allow the Next.js frontend (port 3006) to call us
|
||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const { pathname } = parseURL(req)
|
||||
|
||||
try {
|
||||
// GET /splash — loading screen
|
||||
if (req.method === "GET" && pathname === "/splash") {
|
||||
const splashPath = path.join(ROOT, "splash.html")
|
||||
if (fs.existsSync(splashPath)) {
|
||||
const html = fs.readFileSync(splashPath, "utf-8")
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(html)
|
||||
return
|
||||
}
|
||||
sendJSON(res, 200, { status: "ok" })
|
||||
return
|
||||
}
|
||||
|
||||
// GET /health
|
||||
if (req.method === "GET" && pathname === "/health") {
|
||||
return sendJSON(res, 200, { status: "ok", model: MODEL })
|
||||
}
|
||||
|
||||
// GET /status — combined health of all services
|
||||
// Used by the splash page to check if AI, Scraper, and Frontend are ready.
|
||||
// Polls each service internally to avoid cross-origin CORS issues.
|
||||
if (req.method === "GET" && pathname === "/status") {
|
||||
const { default: http } = await import("http")
|
||||
const results = { ai: true }
|
||||
// Check scraper
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const r = http.get(`${SCRAPER_URL}/health`, { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
|
||||
r.on("error", reject)
|
||||
})
|
||||
results.scraper = true
|
||||
} catch { results.scraper = false }
|
||||
// Check frontend
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const r = http.get(FRONTEND_URL, { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
|
||||
r.on("error", reject)
|
||||
})
|
||||
results.frontend = true
|
||||
} catch { results.frontend = false }
|
||||
return sendJSON(res, 200, results)
|
||||
}
|
||||
|
||||
// ── Setup endpoints ─────────────────────────────────────────
|
||||
|
||||
// GET /setup/status — check environment
|
||||
// Called by the splash page on boot. Returns info about:
|
||||
// - Ollama availability
|
||||
// - Model presence
|
||||
// - Detected browsers with login status
|
||||
// - Whether this is a first run (wizard needed)
|
||||
if (req.method === "GET" && pathname === "/setup/status") {
|
||||
const envExists = fs.existsSync(path.join(ROOT, ".env.local"))
|
||||
|
||||
// Ollama check
|
||||
let ollamaRunning = false
|
||||
try {
|
||||
await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) })
|
||||
ollamaRunning = true
|
||||
} catch {}
|
||||
|
||||
// Model check
|
||||
let modelAvailable = false
|
||||
if (ollamaRunning) {
|
||||
try {
|
||||
const r = await fetch(`${OLLAMA_URL}/api/show`, {
|
||||
method: "POST", body: JSON.stringify({ name: MODEL }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
modelAvailable = r.ok
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Detect all browsers via scraper
|
||||
let browsers = { firefox: { path: null }, opera: { path: null }, chrome: { path: null }, edge: { path: null } }
|
||||
let facebookLoggedIn = false
|
||||
let selectedBrowser = process.env.SELECTED_BROWSER || ""
|
||||
|
||||
try {
|
||||
await fetch(`${SCRAPER_URL}/health`, { signal: AbortSignal.timeout(2000) })
|
||||
const profiles = await (await fetch(`${SCRAPER_URL}/setup/profile`, { signal: AbortSignal.timeout(5000) })).json()
|
||||
for (const [b, p] of Object.entries(profiles)) {
|
||||
if (p) browsers[b] = { path: p }
|
||||
}
|
||||
// Check login for the selected browser first, then try all
|
||||
const detectedList = Object.entries(browsers).filter(([, v]) => v.path)
|
||||
for (const [b, v] of detectedList) {
|
||||
try {
|
||||
const r = await fetch(`${SCRAPER_URL}/setup/check-login`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ browser: b, profile_path: v.path }),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
if (r.ok) {
|
||||
const d = await r.json()
|
||||
browsers[b].logged_in = d.logged_in === true
|
||||
if (d.logged_in && !facebookLoggedIn) {
|
||||
facebookLoggedIn = true
|
||||
if (!selectedBrowser) selectedBrowser = b
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const anyDetected = Object.values(browsers).some(v => v.path)
|
||||
// first_run = any setup step is incomplete
|
||||
const firstRun = !envExists || !ollamaRunning || !anyDetected || !facebookLoggedIn || !modelAvailable
|
||||
|
||||
return sendJSON(res, 200, {
|
||||
first_run: firstRun,
|
||||
env_exists: envExists,
|
||||
ollama_running: ollamaRunning,
|
||||
model_available: modelAvailable,
|
||||
model_name: MODEL,
|
||||
selected_browser: selectedBrowser,
|
||||
browsers,
|
||||
facebook_logged_in: facebookLoggedIn,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /setup/profile — save selected browser + path to .env.local
|
||||
// Called by the setup wizard when the user confirms their browser choice.
|
||||
// Writes SELECTED_BROWSER and the matching profile env var to .env.local.
|
||||
if (req.method === "POST" && pathname === "/setup/profile") {
|
||||
const body = await parseBody(req)
|
||||
const browserName = (body.browser || "").trim().toLowerCase()
|
||||
const profilePath = (body.path || "").trim()
|
||||
if (!browserName || !["firefox", "opera", "chrome", "edge"].includes(browserName))
|
||||
return sendJSON(res, 400, { error: "Valid browser required (firefox/opera/chrome/edge)" })
|
||||
if (!profilePath)
|
||||
return sendJSON(res, 400, { error: "Path required" })
|
||||
|
||||
const envKey = browserName === "firefox" ? "FX_PROFILE"
|
||||
: browserName === "opera" ? "OPERA_PROFILE"
|
||||
: browserName === "edge" ? "EDGE_PROFILE"
|
||||
: "CHROME_PROFILE"
|
||||
|
||||
const envPath = path.join(ROOT, ".env.local")
|
||||
let content = ""
|
||||
try { content = fs.readFileSync(envPath, "utf-8") } catch {}
|
||||
let lines = content.split("\n")
|
||||
// Update or add SELECTED_BROWSER
|
||||
let foundSel = false
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().startsWith("SELECTED_BROWSER=")) {
|
||||
lines[i] = `SELECTED_BROWSER=${browserName}`
|
||||
foundSel = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!foundSel) lines.push(`SELECTED_BROWSER=${browserName}`)
|
||||
// Update or add browser profile
|
||||
let foundProf = false
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().startsWith(`${envKey}=`)) {
|
||||
lines[i] = `${envKey}=${profilePath}`
|
||||
foundProf = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!foundProf) lines.push(`${envKey}=${profilePath}`)
|
||||
fs.writeFileSync(envPath, lines.join("\n"), "utf-8")
|
||||
process.env.SELECTED_BROWSER = browserName
|
||||
process.env[envKey] = profilePath
|
||||
return sendJSON(res, 200, { success: true, browser: browserName, path: profilePath })
|
||||
}
|
||||
|
||||
// POST /setup/check-login — proxy to scraper, accepts browser + profile_path
|
||||
// The splash page calls this (via the AI server) to verify Facebook login status.
|
||||
if (req.method === "POST" && pathname === "/setup/check-login") {
|
||||
const body = await parseBody(req)
|
||||
const browserName = (body.browser || "").trim().toLowerCase() || process.env.SELECTED_BROWSER || ""
|
||||
const profilePath = (body.profile_path || "").trim()
|
||||
|
||||
if (!profilePath) return sendJSON(res, 200, { logged_in: false, reason: "no_profile" })
|
||||
try {
|
||||
const r = await fetch("http://127.0.0.1:3008/setup/check-login", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ browser: browserName, profile_path: profilePath }),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
if (r.ok) { const d = await r.json(); return sendJSON(res, 200, d) }
|
||||
} catch {}
|
||||
return sendJSON(res, 200, { logged_in: false, reason: "scraper_unavailable" })
|
||||
}
|
||||
|
||||
// POST /setup/ollama/pull — start pulling the model
|
||||
// Spawns "ollama pull" as a child process. The setup wizard polls
|
||||
// the progress endpoint to show a download progress bar.
|
||||
if (req.method === "POST" && pathname === "/setup/ollama/pull") {
|
||||
if (pullProcess) return sendJSON(res, 200, { status: "already_running" })
|
||||
pullProgress = { status: "downloading", progress: 0, message: "Starting..." }
|
||||
const isWin = process.platform === "win32"
|
||||
const cmd = isWin ? "ollama.exe" : "ollama"
|
||||
pullProcess = spawn(cmd, ["pull", MODEL], { stdio: ["ignore", "pipe", "pipe"] })
|
||||
|
||||
pullProcess.stdout.on("data", (data) => {
|
||||
const text = data.toString()
|
||||
pullProgress.message = text.trim()
|
||||
// Extract percentage from patterns like "pulling xxxx... 45%"
|
||||
const m = text.match(/(\d+)%/)
|
||||
if (m) pullProgress.progress = parseInt(m[1], 10)
|
||||
})
|
||||
pullProcess.on("close", (code) => {
|
||||
pullProcess = null
|
||||
pullProgress.status = code === 0 ? "done" : "failed"
|
||||
if (code === 0) pullProgress.progress = 100
|
||||
})
|
||||
return sendJSON(res, 200, { status: "started" })
|
||||
}
|
||||
|
||||
// GET /setup/ollama/pull/progress
|
||||
// Returns current download progress for the setup wizard.
|
||||
if (req.method === "GET" && pathname === "/setup/ollama/pull/progress") {
|
||||
return sendJSON(res, 200, pullProgress)
|
||||
}
|
||||
|
||||
// GET /ai/jobs — return loaded job categories
|
||||
if (req.method === "GET" && pathname === "/ai/jobs") {
|
||||
return sendJSON(res, 200, { jobs: loadedJobs })
|
||||
}
|
||||
|
||||
// GET /ai/instructions — return current ai.md content
|
||||
if (req.method === "GET" && pathname === "/ai/instructions") {
|
||||
const instructions = readInstructions()
|
||||
return sendJSON(res, 200, { success: true, instructions })
|
||||
}
|
||||
|
||||
// POST /ai/instructions — update ai.md or append improvement log entry
|
||||
if (req.method === "POST" && pathname === "/ai/instructions") {
|
||||
const body = await parseBody(req)
|
||||
if (body.content) {
|
||||
writeInstructions(body.content)
|
||||
} else if (body.entry) {
|
||||
appendToImprovementLog(body.entry)
|
||||
}
|
||||
return sendJSON(res, 200, {
|
||||
success: true,
|
||||
instructions: readInstructions(),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /ai/chat — main AI chat endpoint
|
||||
// Accepts { message, user_id?, user_role? } and returns AI response.
|
||||
// user_role must be "sales", "admin", or "super_admin" if provided.
|
||||
if (req.method === "POST" && pathname === "/ai/chat") {
|
||||
const chunks = []
|
||||
req.on("data", c => chunks.push(c))
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
const rawBody = Buffer.concat(chunks).toString()
|
||||
const body = JSON.parse(rawBody)
|
||||
const { message, user_id, user_role } = body
|
||||
if (!message) {
|
||||
return sendJSON(res, 400, { error: "message is required" })
|
||||
}
|
||||
const validRoles = ["sales", "admin", "super_admin"]
|
||||
if (user_role && !validRoles.includes(user_role)) {
|
||||
return sendJSON(res, 403, { error: "Forbidden" })
|
||||
}
|
||||
const response = await handleChat(message, user_id || "", user_role || "sales")
|
||||
sendJSON(res, 200, { response })
|
||||
} catch (e) {
|
||||
if (!res.headersSent) sendJSON(res, 500, { error: e.message })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 404 fallback
|
||||
sendJSON(res, 404, { error: "Not found" })
|
||||
} catch (err) {
|
||||
console.error("Request error:", err)
|
||||
sendJSON(res, 500, { error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Start ───────────────────────────────────────────────────────
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`CRM AI server listening on http://${HOST}:${PORT}`)
|
||||
console.log(` Model: ${MODEL}`)
|
||||
console.log(` Ollama: ${OLLAMA_URL}`)
|
||||
})
|
||||
|
||||
initPg()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||
@@ -126,10 +126,10 @@ separate 1:N child tables.
|
||||
|
||||
| Username | Password | Role |
|
||||
|----------|----------|------|
|
||||
| `superadmin_demo` | `SuperAdmin@2026` | SUPER_ADMIN |
|
||||
| `admin_demo` | `AdminAccess@2026` | ADMIN |
|
||||
| `sales_demo` | `SalesAccess@2026` | SALES_USER |
|
||||
| `dev_demo` | `DevTesting@2026` | DEVELOPER |
|
||||
| `superadmin_demo` | `[REDACTED]` | SUPER_ADMIN |
|
||||
| `admin_demo` | `[REDACTED]` | ADMIN |
|
||||
| `sales_demo` | `[REDACTED]` | SALES_USER |
|
||||
| `dev_demo` | `[REDACTED]` | DEVELOPER |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ $$ LANGUAGE plpgsql;
|
||||
-- 1. AUTHENTICATION & ROLE MANAGEMENT
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE roles (
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(50) NOT NULL,
|
||||
display_name VARCHAR(100),
|
||||
@@ -35,7 +35,7 @@ CREATE TABLE roles (
|
||||
|
||||
CREATE UNIQUE INDEX uq_roles_name ON roles(name);
|
||||
|
||||
CREATE TABLE permissions (
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
resource VARCHAR(100) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
@@ -46,7 +46,7 @@ CREATE TABLE permissions (
|
||||
|
||||
CREATE UNIQUE INDEX uq_permissions ON permissions(resource, action);
|
||||
|
||||
CREATE TABLE role_permissions (
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||
granted_by UUID,
|
||||
@@ -54,7 +54,7 @@ CREATE TABLE role_permissions (
|
||||
PRIMARY KEY (role_id, permission_id)
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
username VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
@@ -83,7 +83,7 @@ CREATE INDEX idx_users_is_active ON users(is_active) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_users_is_locked ON users(is_locked) WHERE is_locked = TRUE AND deleted_at IS NULL;
|
||||
CREATE INDEX idx_users_created_by ON users(created_by);
|
||||
|
||||
CREATE TABLE user_roles (
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
assigned_by UUID REFERENCES users(id),
|
||||
@@ -98,7 +98,7 @@ CREATE INDEX idx_user_roles_role ON user_roles(role_id);
|
||||
-- 2. BAN & SUSPENSION SYSTEM
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE banned_users (
|
||||
CREATE TABLE IF NOT EXISTS banned_users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
banned_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
banned_by UUID NOT NULL REFERENCES users(id),
|
||||
@@ -122,7 +122,7 @@ CREATE INDEX idx_banned_users_active ON banned_users(banned_user_id)
|
||||
WHERE is_reversed = FALSE;
|
||||
CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by);
|
||||
|
||||
CREATE TABLE suspended_users (
|
||||
CREATE TABLE IF NOT EXISTS suspended_users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
suspended_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
suspended_by UUID NOT NULL REFERENCES users(id),
|
||||
@@ -145,7 +145,7 @@ CREATE INDEX idx_suspended_users_suspended_by ON suspended_users(suspended_by);
|
||||
-- 3. SESSION & LOGIN MANAGEMENT
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE sessions (
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
@@ -161,7 +161,7 @@ CREATE INDEX idx_sessions_user ON sessions(user_id) WHERE is_active = TRUE;
|
||||
CREATE UNIQUE INDEX uq_sessions_token ON sessions(token_hash);
|
||||
CREATE INDEX idx_sessions_expires ON sessions(expires_at) WHERE is_active = TRUE;
|
||||
|
||||
CREATE TABLE login_attempts (
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID REFERENCES users(id),
|
||||
username_attempted VARCHAR(100) NOT NULL,
|
||||
@@ -181,7 +181,7 @@ CREATE INDEX idx_login_attempts_time ON login_attempts(attempted_at DESC);
|
||||
-- 4. CUSTOMER MANAGEMENT
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE customer_statuses (
|
||||
CREATE TABLE IF NOT EXISTS customer_statuses (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
@@ -196,7 +196,7 @@ CREATE TABLE customer_statuses (
|
||||
|
||||
CREATE UNIQUE INDEX uq_customer_statuses_name ON customer_statuses(name) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE customers (
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
customer_type VARCHAR(20) NOT NULL,
|
||||
status_id UUID NOT NULL REFERENCES customer_statuses(id),
|
||||
@@ -218,7 +218,7 @@ CREATE INDEX idx_customers_score ON customers(score DESC) WHERE deleted_at IS NU
|
||||
CREATE INDEX idx_customers_tags ON customers USING GIN(tags) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_customers_created ON customers(created_at DESC) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE individual_customers (
|
||||
CREATE TABLE IF NOT EXISTS individual_customers (
|
||||
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
@@ -233,7 +233,7 @@ CREATE TABLE individual_customers (
|
||||
|
||||
CREATE INDEX idx_individual_names ON individual_customers(last_name, first_name);
|
||||
|
||||
CREATE TABLE company_customers (
|
||||
CREATE TABLE IF NOT EXISTS company_customers (
|
||||
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
|
||||
company_name VARCHAR(255) NOT NULL,
|
||||
registration_number VARCHAR(100),
|
||||
@@ -250,7 +250,7 @@ CREATE TABLE company_customers (
|
||||
CREATE INDEX idx_company_name ON company_customers(company_name);
|
||||
CREATE INDEX idx_company_industry ON company_customers(industry);
|
||||
|
||||
CREATE TABLE contact_information (
|
||||
CREATE TABLE IF NOT EXISTS contact_information (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
@@ -270,7 +270,7 @@ CREATE INDEX idx_contacts_type ON contact_information(type) WHERE deleted_at IS
|
||||
CREATE INDEX idx_contacts_value ON contact_information(value) WHERE deleted_at IS NULL;
|
||||
CREATE UNIQUE INDEX uq_contacts_primary ON contact_information(customer_id, type) WHERE is_primary = TRUE AND deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE addresses (
|
||||
CREATE TABLE IF NOT EXISTS addresses (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
address_type VARCHAR(20) NOT NULL,
|
||||
@@ -294,7 +294,7 @@ CREATE INDEX idx_addresses_country ON addresses(country) WHERE deleted_at IS NUL
|
||||
CREATE INDEX idx_addresses_city ON addresses(city) WHERE deleted_at IS NULL;
|
||||
CREATE UNIQUE INDEX uq_addresses_primary ON addresses(customer_id, address_type) WHERE is_primary = TRUE AND deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE customer_notes (
|
||||
CREATE TABLE IF NOT EXISTS customer_notes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
author_id UUID NOT NULL REFERENCES users(id),
|
||||
@@ -315,7 +315,7 @@ CREATE INDEX idx_notes_pinned ON customer_notes(is_pinned) WHERE is_pinned = TRU
|
||||
-- 5. CUSTOMER & INCIDENT REPORTS
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE customer_reports (
|
||||
CREATE TABLE IF NOT EXISTS customer_reports (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||
reported_by UUID NOT NULL REFERENCES users(id),
|
||||
@@ -339,7 +339,7 @@ CREATE INDEX idx_customer_reports_reported_by ON customer_reports(reported_by);
|
||||
CREATE INDEX idx_customer_reports_status ON customer_reports(status);
|
||||
CREATE INDEX idx_customer_reports_severity ON customer_reports(severity);
|
||||
|
||||
CREATE TABLE incident_reports (
|
||||
CREATE TABLE IF NOT EXISTS incident_reports (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
reported_by UUID NOT NULL REFERENCES users(id),
|
||||
target_user_id UUID REFERENCES users(id),
|
||||
@@ -368,7 +368,7 @@ CREATE INDEX idx_incident_reports_severity ON incident_reports(severity);
|
||||
-- 6. LEAD MANAGEMENT
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE lead_sources (
|
||||
CREATE TABLE IF NOT EXISTS lead_sources (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
@@ -380,7 +380,7 @@ CREATE TABLE lead_sources (
|
||||
|
||||
CREATE UNIQUE INDEX uq_lead_sources_name ON lead_sources(name) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE lead_stages (
|
||||
CREATE TABLE IF NOT EXISTS lead_stages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
@@ -395,7 +395,7 @@ CREATE TABLE lead_stages (
|
||||
CREATE UNIQUE INDEX uq_lead_stages_name ON lead_stages(name) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_lead_stages_sort ON lead_stages(sort_order);
|
||||
|
||||
CREATE TABLE leads (
|
||||
CREATE TABLE IF NOT EXISTS leads (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
source_id UUID REFERENCES lead_sources(id),
|
||||
stage_id UUID NOT NULL REFERENCES lead_stages(id),
|
||||
@@ -431,7 +431,7 @@ CREATE INDEX idx_leads_converted ON leads(converted_customer_id) WHERE converted
|
||||
CREATE INDEX idx_leads_email ON leads(email) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_leads_active ON leads(stage_id, assigned_to) WHERE deleted_at IS NULL AND converted_customer_id IS NULL;
|
||||
|
||||
CREATE TABLE lead_conversions (
|
||||
CREATE TABLE IF NOT EXISTS lead_conversions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
@@ -447,7 +447,7 @@ CREATE INDEX idx_lead_conversions_customer ON lead_conversions(customer_id);
|
||||
-- 7. PRODUCT CATALOG
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE product_categories (
|
||||
CREATE TABLE IF NOT EXISTS product_categories (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
@@ -462,7 +462,7 @@ CREATE TABLE product_categories (
|
||||
CREATE UNIQUE INDEX uq_product_categories_name ON product_categories(name) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_product_categories_parent ON product_categories(parent_id);
|
||||
|
||||
CREATE TABLE products (
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
category_id UUID REFERENCES product_categories(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
@@ -487,7 +487,7 @@ CREATE INDEX idx_products_active ON products(is_active) WHERE deleted_at IS NULL
|
||||
-- 8. SALES PIPELINE
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE deal_stages (
|
||||
CREATE TABLE IF NOT EXISTS deal_stages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
@@ -502,7 +502,7 @@ CREATE TABLE deal_stages (
|
||||
CREATE UNIQUE INDEX uq_deal_stages_name ON deal_stages(name) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_deal_stages_sort ON deal_stages(sort_order);
|
||||
|
||||
CREATE TABLE opportunities (
|
||||
CREATE TABLE IF NOT EXISTS opportunities (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||
lead_id UUID REFERENCES leads(id),
|
||||
@@ -530,7 +530,7 @@ CREATE INDEX idx_opportunities_won ON opportunities(is_won) WHERE deleted_at IS
|
||||
CREATE INDEX idx_opportunities_created ON opportunities(created_at DESC) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_opportunities_owner_stage ON opportunities(owner_id, stage_id) WHERE deleted_at IS NULL AND is_won IS NULL;
|
||||
|
||||
CREATE TABLE opportunity_products (
|
||||
CREATE TABLE IF NOT EXISTS opportunity_products (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE,
|
||||
product_id UUID NOT NULL REFERENCES products(id),
|
||||
@@ -549,7 +549,7 @@ CREATE INDEX idx_opp_products_product ON opportunity_products(product_id);
|
||||
-- 9. COMMUNICATION TRACKING
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE communication_types (
|
||||
CREATE TABLE IF NOT EXISTS communication_types (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(50) NOT NULL,
|
||||
description TEXT,
|
||||
@@ -561,7 +561,7 @@ CREATE TABLE communication_types (
|
||||
|
||||
CREATE UNIQUE INDEX uq_comm_types_name ON communication_types(name);
|
||||
|
||||
CREATE TABLE communications (
|
||||
CREATE TABLE IF NOT EXISTS communications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||
opportunity_id UUID REFERENCES opportunities(id),
|
||||
@@ -585,7 +585,7 @@ CREATE INDEX idx_communications_type ON communications(type_id) WHERE deleted_at
|
||||
CREATE INDEX idx_communications_created_by ON communications(created_by) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_communications_started ON communications(started_at) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE communication_participants (
|
||||
CREATE TABLE IF NOT EXISTS communication_participants (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
communication_id UUID NOT NULL REFERENCES communications(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id),
|
||||
@@ -602,7 +602,7 @@ CREATE INDEX idx_comm_participants_user ON communication_participants(user_id);
|
||||
-- 10. TASKS AND ACTIVITIES
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE task_priorities (
|
||||
CREATE TABLE IF NOT EXISTS task_priorities (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(50) NOT NULL,
|
||||
color VARCHAR(7),
|
||||
@@ -613,7 +613,7 @@ CREATE TABLE task_priorities (
|
||||
|
||||
CREATE UNIQUE INDEX uq_task_priorities_name ON task_priorities(name);
|
||||
|
||||
CREATE TABLE tasks (
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
customer_id UUID REFERENCES customers(id),
|
||||
opportunity_id UUID REFERENCES opportunities(id),
|
||||
@@ -645,7 +645,7 @@ CREATE INDEX idx_tasks_reminder ON tasks(reminder_at) WHERE reminder_sent = FALS
|
||||
-- 11. INVOICE SUPPORT
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE invoice_statuses (
|
||||
CREATE TABLE IF NOT EXISTS invoice_statuses (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(50) NOT NULL,
|
||||
description TEXT,
|
||||
@@ -657,7 +657,7 @@ CREATE TABLE invoice_statuses (
|
||||
|
||||
CREATE UNIQUE INDEX uq_invoice_statuses_name ON invoice_statuses(name);
|
||||
|
||||
CREATE TABLE invoices (
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||
opportunity_id UUID REFERENCES opportunities(id),
|
||||
@@ -689,7 +689,7 @@ CREATE INDEX idx_invoices_issued ON invoices(issued_date DESC) WHERE deleted_at
|
||||
CREATE INDEX idx_invoices_due ON invoices(due_date) WHERE deleted_at IS NULL;
|
||||
-- Skipped: cannot use NOW() in partial index predicate (not IMMUTABLE)
|
||||
|
||||
CREATE TABLE invoice_items (
|
||||
CREATE TABLE IF NOT EXISTS invoice_items (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
product_id UUID REFERENCES products(id),
|
||||
@@ -705,7 +705,7 @@ CREATE TABLE invoice_items (
|
||||
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
|
||||
CREATE INDEX idx_invoice_items_product ON invoice_items(product_id);
|
||||
|
||||
CREATE TABLE payments (
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id),
|
||||
amount DECIMAL(15,2) NOT NULL CHECK (amount > 0),
|
||||
@@ -729,7 +729,7 @@ CREATE INDEX idx_payments_transaction ON payments(transaction_id) WHERE transact
|
||||
-- 12. AUDIT LOGGING
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE audit_logs (
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
table_name VARCHAR(100) NOT NULL,
|
||||
record_id UUID NOT NULL,
|
||||
@@ -755,7 +755,7 @@ CREATE INDEX idx_audit_session ON audit_logs(session_id);
|
||||
-- 13. DUPLICATE DETECTION
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE customer_duplicates (
|
||||
CREATE TABLE IF NOT EXISTS customer_duplicates (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
duplicate_customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
@@ -810,11 +810,11 @@ BEGIN
|
||||
)
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER trg_%I_updated_at
|
||||
'DROP TRIGGER IF EXISTS trg_%I_updated_at ON %I; CREATE TRIGGER trg_%I_updated_at
|
||||
BEFORE UPDATE ON %I
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column()',
|
||||
t, t
|
||||
t, t, t, t
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
@@ -885,6 +885,7 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_enforce_create_user ON users;
|
||||
CREATE TRIGGER trg_enforce_create_user
|
||||
BEFORE INSERT ON users
|
||||
FOR EACH ROW
|
||||
@@ -935,6 +936,7 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_enforce_role_assignment ON user_roles;
|
||||
CREATE TRIGGER trg_enforce_role_assignment
|
||||
BEFORE INSERT ON user_roles
|
||||
FOR EACH ROW
|
||||
@@ -973,7 +975,7 @@ BEGIN
|
||||
audit_action,
|
||||
CASE WHEN audit_action IN ('UPDATE', 'DELETE') THEN old_row ELSE NULL END,
|
||||
CASE WHEN audit_action IN ('CREATE', 'UPDATE') THEN new_row ELSE NULL END,
|
||||
NULL
|
||||
NULLIF(current_setting('app.current_user_id', true), '')
|
||||
);
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
@@ -998,6 +1000,7 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_ban ON banned_users;
|
||||
CREATE TRIGGER trg_audit_ban
|
||||
AFTER INSERT OR UPDATE ON banned_users
|
||||
FOR EACH ROW
|
||||
@@ -1021,6 +1024,7 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_suspension ON suspended_users;
|
||||
CREATE TRIGGER trg_audit_suspension
|
||||
AFTER INSERT OR UPDATE ON suspended_users
|
||||
FOR EACH ROW
|
||||
@@ -1051,6 +1055,7 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts;
|
||||
CREATE TRIGGER trg_audit_login
|
||||
AFTER INSERT ON login_attempts
|
||||
FOR EACH ROW
|
||||
|
||||
@@ -195,6 +195,9 @@ ON CONFLICT DO NOTHING;
|
||||
-- admin_demo / AdminAccess@2026
|
||||
-- sales_demo / SalesAccess@2026
|
||||
-- dev_demo / DevTesting@2026
|
||||
-- ewan (password change required on first login)
|
||||
-- caitlin (password change required on first login)
|
||||
-- dillen (password change required on first login)
|
||||
|
||||
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
|
||||
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
|
||||
@@ -208,7 +211,16 @@ INSERT INTO users (id, username, email, password_hash, first_name, last_name, is
|
||||
'Sales', 'User', TRUE, TRUE),
|
||||
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
|
||||
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
|
||||
'Dev', 'User', TRUE, TRUE)
|
||||
'Dev', 'User', TRUE, TRUE),
|
||||
('00000000-0000-0000-0000-000000000005', 'ewan', 'ewan@coastit.co.za',
|
||||
'$2b$12$nO.9p.f4oWFhfScxM8MGQuiR9YjU85YTIqcb1kS.kyDBMHdmQ.EyG',
|
||||
'Ewan', 'Scheepers', TRUE, TRUE),
|
||||
('00000000-0000-0000-0000-000000000006', 'caitlin', 'caitlin@coastit.co.za',
|
||||
'$2b$12$I5FHjje4OA5raP3642twT.Wmcl00rA1/wDPiQjRK14yDgybUjmrYG',
|
||||
'Caitlin', 'Hermanus', TRUE, TRUE),
|
||||
('00000000-0000-0000-0000-000000000007', 'dillen', 'dillen@coastit.co.za',
|
||||
'$2b$12$QnvaRzdJEV/Bq8tp5vguM.ad.oeAcV2bjdzndIFdA4Opn5vY2WVaW',
|
||||
'Dillen', 'van der Merwe', TRUE, TRUE)
|
||||
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
|
||||
|
||||
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
|
||||
@@ -217,7 +229,7 @@ WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL;
|
||||
|
||||
-- Set created_by for other users (SUPER_ADMIN created them)
|
||||
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004')
|
||||
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004','00000000-0000-0000-0000-000000000005','00000000-0000-0000-0000-000000000006','00000000-0000-0000-0000-000000000007')
|
||||
AND created_by IS NULL;
|
||||
|
||||
-- ============================================================================
|
||||
@@ -233,7 +245,10 @@ ON CONFLICT DO NOTHING;
|
||||
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
|
||||
('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||
('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
|
||||
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||
('00000000-0000-0000-0000-000000000005', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||
('00000000-0000-0000-0000-000000000006', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||
('00000000-0000-0000-0000-000000000007', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
-- ============================================================================
|
||||
-- Chat System: conversations, participants, and messages
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_participants_user_id ON conversation_participants(user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_created ON messages(conversation_id, created_at DESC);
|
||||
|
||||
-- Seed conversations between superadmin and other users
|
||||
INSERT INTO conversations (id, created_at) VALUES
|
||||
('c0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 hours'),
|
||||
('c0000000-0000-0000-0000-000000000002', NOW() - INTERVAL '1 hour'),
|
||||
('c0000000-0000-0000-0000-000000000003', NOW() - INTERVAL '30 minutes')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Add participants (superadmin + each sales/dev user)
|
||||
INSERT INTO conversation_participants (conversation_id, user_id) VALUES
|
||||
('c0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001'),
|
||||
('c0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003'),
|
||||
('c0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001'),
|
||||
('c0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000004'),
|
||||
('c0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001'),
|
||||
('c0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000002')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Seed some messages
|
||||
INSERT INTO messages (id, conversation_id, sender_id, content, created_at) VALUES
|
||||
('00000000-0000-0000-0000-000000000101', 'c0000000-0000-0000-0000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000003', 'Hey! Just finalized the TechCorp deal.',
|
||||
NOW() - INTERVAL '2 hours'),
|
||||
('00000000-0000-0000-0000-000000000102', 'c0000000-0000-0000-0000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000001', 'Great work! What were the final terms?',
|
||||
NOW() - INTERVAL '105 minutes'),
|
||||
('00000000-0000-0000-0000-000000000103', 'c0000000-0000-0000-0000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000003', '3-year enterprise license, 15% discount. They signed this morning.',
|
||||
NOW() - INTERVAL '100 minutes'),
|
||||
('00000000-0000-0000-0000-000000000104', 'c0000000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000004', 'The API integration tests are passing on staging.',
|
||||
NOW() - INTERVAL '50 minutes'),
|
||||
('00000000-0000-0000-0000-000000000105', 'c0000000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000001', 'Great, when can we deploy to production?',
|
||||
NOW() - INTERVAL '45 minutes'),
|
||||
('00000000-0000-0000-0000-000000000106', 'c0000000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000004', 'Tomorrow morning after final review.',
|
||||
NOW() - INTERVAL '40 minutes'),
|
||||
('00000000-0000-0000-0000-000000000107', 'c0000000-0000-0000-0000-000000000003',
|
||||
'00000000-0000-0000-0000-000000000002', 'New report shows 23% increase in lead conversion this quarter.',
|
||||
NOW() - INTERVAL '25 minutes'),
|
||||
('00000000-0000-0000-0000-000000000108', 'c0000000-0000-0000-0000-000000000003',
|
||||
'00000000-0000-0000-0000-000000000001', 'That is excellent! Let us discuss in the next team meeting.',
|
||||
NOW() - INTERVAL '20 minutes')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Update conversation timestamps
|
||||
UPDATE conversations SET updated_at = NOW() - INTERVAL '20 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000003';
|
||||
UPDATE conversations SET updated_at = NOW() - INTERVAL '40 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000002';
|
||||
UPDATE conversations SET updated_at = NOW() - INTERVAL '100 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000001';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE conversation_participants ADD COLUMN IF NOT EXISTS last_read_at TIMESTAMPTZ;
|
||||
UPDATE conversation_participants SET last_read_at = NOW() WHERE last_read_at IS NULL;
|
||||
@@ -0,0 +1,87 @@
|
||||
-- Test leads - 2026-06-18
|
||||
INSERT INTO leads (id, stage_id, assigned_to, company_name, contact_name, email, phone, created_at, updated_at) VALUES
|
||||
('c0011001-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 1', 'Contact 1', 'contact1@test.com', '555-2636', '2025-12-23T22:09:54Z', '2025-12-23T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 2', 'Contact 2', 'contact2@test.com', '555-5691', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 3', 'Contact 3', 'contact3@test.com', '555-6446', '2025-12-24T22:09:54Z', '2025-12-24T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 4', 'Contact 4', 'contact4@test.com', '555-5969', '2026-01-02T22:09:54Z', '2026-01-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 5', 'Contact 5', 'contact5@test.com', '555-4200', '2026-01-14T22:09:54Z', '2026-01-14T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 6', 'Contact 6', 'contact6@test.com', '555-8324', '2026-01-03T22:09:54Z', '2026-01-03T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000007', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 7', 'Contact 7', 'contact7@test.com', '555-1047', '2025-12-27T22:09:54Z', '2025-12-27T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000008', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 8', 'Contact 8', 'contact8@test.com', '555-634', '2026-01-20T22:09:54Z', '2026-01-20T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000009', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 9', 'Contact 9', 'contact9@test.com', '555-3245', '2026-01-15T22:09:54Z', '2026-01-15T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000010', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 10', 'Contact 10', 'contact10@test.com', '555-3656', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000011', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 11', 'Contact 11', 'contact11@test.com', '555-2993', '2026-01-23T22:09:54Z', '2026-01-23T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000012', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 12', 'Contact 12', 'contact12@test.com', '555-1634', '2026-02-24T22:09:54Z', '2026-02-24T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000013', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 13', 'Contact 13', 'contact13@test.com', '555-2032', '2026-02-10T22:09:54Z', '2026-02-10T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000014', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 14', 'Contact 14', 'contact14@test.com', '555-867', '2026-01-31T22:09:54Z', '2026-01-31T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000015', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 15', 'Contact 15', 'contact15@test.com', '555-2136', '2026-02-02T22:09:54Z', '2026-02-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000016', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 16', 'Contact 16', 'contact16@test.com', '555-89', '2026-01-20T22:09:54Z', '2026-01-20T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000017', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 17', 'Contact 17', 'contact17@test.com', '555-4274', '2026-01-25T22:09:54Z', '2026-01-25T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000018', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 18', 'Contact 18', 'contact18@test.com', '555-4294', '2026-03-16T22:09:54Z', '2026-03-16T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000019', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 19', 'Contact 19', 'contact19@test.com', '555-7998', '2025-12-30T22:09:54Z', '2025-12-30T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000020', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 20', 'Contact 20', 'contact20@test.com', '555-8557', '2026-02-13T22:09:54Z', '2026-02-13T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000021', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 21', 'Contact 21', 'contact21@test.com', '555-5170', '2026-04-11T22:09:54Z', '2026-04-11T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000022', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 22', 'Contact 22', 'contact22@test.com', '555-6250', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000023', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 23', 'Contact 23', 'contact23@test.com', '555-5611', '2026-01-06T22:09:54Z', '2026-01-06T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000024', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 24', 'Contact 24', 'contact24@test.com', '555-3467', '2026-04-16T22:09:54Z', '2026-04-16T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000025', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 25', 'Contact 25', 'contact25@test.com', '555-7087', '2026-05-07T22:09:54Z', '2026-05-07T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000026', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 26', 'Contact 26', 'contact26@test.com', '555-2945', '2025-12-22T22:09:54Z', '2025-12-22T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000027', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 27', 'Contact 27', 'contact27@test.com', '555-9502', '2026-01-09T22:09:54Z', '2026-01-09T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000028', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 28', 'Contact 28', 'contact28@test.com', '555-8114', '2026-05-10T22:09:54Z', '2026-05-10T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000029', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 29', 'Contact 29', 'contact29@test.com', '555-4926', '2026-03-02T22:09:54Z', '2026-03-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000030', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 30', 'Contact 30', 'contact30@test.com', '555-2421', '2026-03-17T22:09:54Z', '2026-03-17T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000031', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 31', 'Contact 31', 'contact31@test.com', '555-7506', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000032', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 32', 'Contact 32', 'contact32@test.com', '555-6832', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000033', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 33', 'Contact 33', 'contact33@test.com', '555-6548', '2026-01-07T22:09:54Z', '2026-01-07T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000034', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 34', 'Contact 34', 'contact34@test.com', '555-8793', '2026-01-02T22:09:54Z', '2026-01-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000035', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 35', 'Contact 35', 'contact35@test.com', '555-6217', '2026-01-29T22:09:54Z', '2026-01-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000036', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 36', 'Contact 36', 'contact36@test.com', '555-9206', '2025-12-31T22:09:54Z', '2025-12-31T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000037', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 37', 'Contact 37', 'contact37@test.com', '555-1599', '2025-12-26T22:09:54Z', '2025-12-26T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000038', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 38', 'Contact 38', 'contact38@test.com', '555-3873', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000039', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 39', 'Contact 39', 'contact39@test.com', '555-6122', '2026-01-09T22:09:54Z', '2026-01-09T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000040', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 40', 'Contact 40', 'contact40@test.com', '555-832', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000041', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 41', 'Contact 41', 'contact41@test.com', '555-5979', '2026-01-11T22:09:54Z', '2026-01-11T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000042', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 42', 'Contact 42', 'contact42@test.com', '555-1823', '2025-12-26T22:09:54Z', '2025-12-26T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000043', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 43', 'Contact 43', 'contact43@test.com', '555-7102', '2026-02-28T22:09:54Z', '2026-02-28T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000044', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 44', 'Contact 44', 'contact44@test.com', '555-5106', '2026-01-22T22:09:54Z', '2026-01-22T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000045', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 45', 'Contact 45', 'contact45@test.com', '555-3278', '2026-03-27T22:09:54Z', '2026-03-27T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000046', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 46', 'Contact 46', 'contact46@test.com', '555-4973', '2026-04-28T22:09:54Z', '2026-04-28T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000047', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 47', 'Contact 47', 'contact47@test.com', '555-3297', '2026-03-01T22:09:54Z', '2026-03-01T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000048', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 48', 'Contact 48', 'contact48@test.com', '555-4584', '2026-03-18T22:09:54Z', '2026-03-18T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000049', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 49', 'Contact 49', 'contact49@test.com', '555-6788', '2026-03-04T22:09:54Z', '2026-03-04T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000050', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 50', 'Contact 50', 'contact50@test.com', '555-2734', '2026-01-03T22:09:54Z', '2026-01-03T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000051', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 51', 'Contact 51', 'contact51@test.com', '555-2625', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000052', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 52', 'Contact 52', 'contact52@test.com', '555-620', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000053', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 53', 'Contact 53', 'contact53@test.com', '555-6691', '2025-12-22T22:09:54Z', '2025-12-22T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000054', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 54', 'Contact 54', 'contact54@test.com', '555-8847', '2026-01-25T22:09:54Z', '2026-01-25T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000055', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 55', 'Contact 55', 'contact55@test.com', '555-2187', '2026-02-02T22:09:54Z', '2026-02-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000056', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 56', 'Contact 56', 'contact56@test.com', '555-3267', '2026-01-19T22:09:54Z', '2026-01-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000057', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 57', 'Contact 57', 'contact57@test.com', '555-8119', '2026-03-05T22:09:54Z', '2026-03-05T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000058', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 58', 'Contact 58', 'contact58@test.com', '555-3181', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000059', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 59', 'Contact 59', 'contact59@test.com', '555-4403', '2026-02-23T22:09:54Z', '2026-02-23T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000060', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 60', 'Contact 60', 'contact60@test.com', '555-1407', '2026-04-06T22:09:54Z', '2026-04-06T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000061', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 61', 'Contact 61', 'contact61@test.com', '555-2333', '2026-02-27T22:09:54Z', '2026-02-27T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000062', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 62', 'Contact 62', 'contact62@test.com', '555-1436', '2026-04-26T22:09:54Z', '2026-04-26T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000063', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 63', 'Contact 63', 'contact63@test.com', '555-7135', '2026-04-19T22:09:54Z', '2026-04-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000064', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 64', 'Contact 64', 'contact64@test.com', '555-9366', '2026-01-22T22:09:54Z', '2026-01-22T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000065', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 65', 'Contact 65', 'contact65@test.com', '555-7135', '2026-02-18T22:09:54Z', '2026-02-18T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000066', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 66', 'Contact 66', 'contact66@test.com', '555-6854', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000067', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 67', 'Contact 67', 'contact67@test.com', '555-2322', '2025-12-24T22:09:54Z', '2025-12-24T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000068', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 68', 'Contact 68', 'contact68@test.com', '555-2632', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000069', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 69', 'Contact 69', 'contact69@test.com', '555-876', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000070', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 70', 'Contact 70', 'contact70@test.com', '555-974', '2026-01-15T22:09:54Z', '2026-01-15T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000071', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 71', 'Contact 71', 'contact71@test.com', '555-1919', '2026-01-18T22:09:54Z', '2026-01-18T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000072', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 72', 'Contact 72', 'contact72@test.com', '555-3034', '2025-12-25T22:09:54Z', '2025-12-25T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000073', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 73', 'Contact 73', 'contact73@test.com', '555-1795', '2026-01-27T22:09:54Z', '2026-01-27T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000074', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 74', 'Contact 74', 'contact74@test.com', '555-727', '2026-01-08T22:09:54Z', '2026-01-08T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000075', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 75', 'Contact 75', 'contact75@test.com', '555-3877', '2026-03-08T22:09:54Z', '2026-03-08T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000076', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 76', 'Contact 76', 'contact76@test.com', '555-6384', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000077', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 77', 'Contact 77', 'contact77@test.com', '555-5607', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000078', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 78', 'Contact 78', 'contact78@test.com', '555-6225', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000079', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 79', 'Contact 79', 'contact79@test.com', '555-9288', '2026-04-16T22:09:54Z', '2026-04-16T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000080', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 80', 'Contact 80', 'contact80@test.com', '555-8660', '2026-02-16T22:09:54Z', '2026-02-16T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000081', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 81', 'Contact 81', 'contact81@test.com', '555-826', '2025-12-28T22:09:54Z', '2025-12-28T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000082', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 82', 'Contact 82', 'contact82@test.com', '555-1761', '2026-03-11T22:09:54Z', '2026-03-11T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000083', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 83', 'Contact 83', 'contact83@test.com', '555-9860', '2026-05-18T22:09:54Z', '2026-05-18T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000084', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 84', 'Contact 84', 'contact84@test.com', '555-1104', '2026-06-01T22:09:54Z', '2026-06-01T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000085', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 85', 'Contact 85', 'contact85@test.com', '555-9528', '2026-06-14T22:09:54Z', '2026-06-14T22:09:54Z');
|
||||
@@ -0,0 +1,13 @@
|
||||
-- AI Sales Assistant tables
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_conversations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'sales',
|
||||
message TEXT NOT NULL,
|
||||
response TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user ON ai_conversations(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_conversations_created ON ai_conversations(created_at DESC);
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
link TEXT,
|
||||
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, created_at DESC) WHERE is_read = FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
lead_assigned BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lead_status BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
note_added BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
daily_digest BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
weekly_report BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS company_settings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
company_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
company_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||
company_phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||
company_website VARCHAR(255) NOT NULL DEFAULT '',
|
||||
company_address TEXT NOT NULL DEFAULT '',
|
||||
updated_by UUID REFERENCES users(id),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO company_settings (company_name, company_email, company_phone, company_website, company_address)
|
||||
VALUES ('Coastal IT Solutions', 'info@coastalit.com', '(555) 123-4567', 'https://coastalit.com', '123 Business Ave, Suite 100, San Francisco, CA 94105')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
timezone VARCHAR(100) NOT NULL DEFAULT 'america-los_angeles',
|
||||
date_format VARCHAR(10) NOT NULL DEFAULT 'mdy',
|
||||
items_per_page INTEGER NOT NULL DEFAULT 20,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_id UUID;
|
||||
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id) WHERE context_type IS NOT NULL;
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE IF NOT EXISTS facebook_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
label VARCHAR(100) NOT NULL,
|
||||
profile_path TEXT NOT NULL,
|
||||
cookie_file TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_scrape_at TIMESTAMPTZ,
|
||||
last_success_at TIMESTAMPTZ,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
last_error_message TEXT,
|
||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||
flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
flagged_at TIMESTAMPTZ,
|
||||
flagged_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fb_accounts_active ON facebook_accounts(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_fb_accounts_flagged ON facebook_accounts(flagged);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS facebook_scrape_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
account_id UUID NOT NULL REFERENCES facebook_accounts(id) ON DELETE CASCADE,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
success BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
leads_found INT NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
detected_flag VARCHAR(50),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account ON facebook_scrape_logs(account_id, created_at DESC);
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE IF NOT EXISTS scheduled_events (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
participant_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
|
||||
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
event_type VARCHAR(20) NOT NULL DEFAULT 'meeting',
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ,
|
||||
duration_minutes INT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_event_type CHECK (event_type IN ('meeting','call','chat','follow_up','demo','other')),
|
||||
CONSTRAINT chk_event_status CHECK (status IN ('scheduled','completed','cancelled','rescheduled'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user ON scheduled_events(user_id, start_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_events_participant ON scheduled_events(participant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_events_lead ON scheduled_events(lead_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_events_date ON scheduled_events(start_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_events_status ON scheduled_events(user_id, status);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- ============================================================================
|
||||
-- Performance & Missing Indexes
|
||||
-- ============================================================================
|
||||
|
||||
-- scheduled_events: index on created_at for list ordering
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_events_created_at ON scheduled_events(created_at DESC);
|
||||
|
||||
-- scheduled_events: composite index for common user+status queries
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user_status ON scheduled_events(user_id, status);
|
||||
|
||||
-- messages: index on sender_id for delete-by-sender queries
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_id);
|
||||
|
||||
-- messages: composite for conversation listing
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_sender ON messages(conversation_id, sender_id);
|
||||
|
||||
-- notifications: composite unread badge query
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read, created_at DESC);
|
||||
|
||||
-- ai_conversations: composite user+created query
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user_created ON ai_conversations(user_id, created_at DESC);
|
||||
|
||||
-- login_attempts: TTL cleanup
|
||||
CREATE INDEX IF NOT EXISTS idx_login_attempts_cleanup ON login_attempts(attempted_at) WHERE was_successful = false;
|
||||
|
||||
-- facebook_scrape_logs: composite account+created index (replaces separate one)
|
||||
DROP INDEX IF EXISTS idx_fb_scrape_logs_account;
|
||||
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account_created ON facebook_scrape_logs(account_id, created_at DESC);
|
||||
|
||||
-- lead_conversions: composite lead+customer (replaces two separate indexes)
|
||||
CREATE INDEX IF NOT EXISTS idx_lead_conversions_lead_customer ON lead_conversions(lead_id, customer_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- Cache current_user_hierarchy_level as session variable for RLS performance
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_session_user_context(p_user_id UUID)
|
||||
RETURNS VOID AS $$
|
||||
DECLARE
|
||||
level INT;
|
||||
BEGIN
|
||||
SELECT MIN(r.hierarchy_level) INTO level
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = p_user_id;
|
||||
|
||||
PERFORM set_config('app.current_user_id', p_user_id::TEXT, true);
|
||||
PERFORM set_config('app.current_user_hierarchy_level', COALESCE(level::TEXT, ''), true);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Update current_user_hierarchy_level() to use the cached session variable
|
||||
CREATE OR REPLACE FUNCTION current_user_hierarchy_level()
|
||||
RETURNS INT AS $$
|
||||
DECLARE
|
||||
level_text TEXT;
|
||||
BEGIN
|
||||
BEGIN
|
||||
level_text := NULLIF(current_setting('app.current_user_hierarchy_level', true), '');
|
||||
IF level_text IS NOT NULL THEN
|
||||
RETURN level_text::INT;
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql STABLE;
|
||||
@@ -4,16 +4,83 @@
|
||||
-- Usage: psql -U postgres -d crm -f run_all.sql
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
\echo '=== Running 001_schema.sql (Tables + Constraints + Functions + Views) ==='
|
||||
\i 001_schema.sql
|
||||
|
||||
\echo '=== Running 002_seed.sql (RBAC + Test Accounts + Reference Data) ==='
|
||||
\i 002_seed.sql
|
||||
|
||||
\echo '=== Running 003_chat.sql (Chat Tables) ==='
|
||||
\i 003_chat.sql
|
||||
|
||||
\echo '=== Running 004_avatar_url.sql (Avatar URL Column) ==='
|
||||
\i 004_avatar_url.sql
|
||||
|
||||
\echo '=== Running 005_last_read_at.sql (Last Read At Column) ==='
|
||||
\i 005_last_read_at.sql
|
||||
|
||||
\echo '=== Running 006_test_leads.sql (Test Lead Data) ==='
|
||||
\i 006_test_leads.sql
|
||||
|
||||
\echo '=== Running 007_ai_features.sql (AI Features) ==='
|
||||
\i 007_ai_features.sql
|
||||
|
||||
\echo '=== Running 008_notifications.sql (Notifications + Preferences) ==='
|
||||
\i 008_notifications.sql
|
||||
|
||||
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
||||
\i 009_settings.sql
|
||||
|
||||
\echo '=== Running 010_chat_notifications.sql (Chat Notifications) ==='
|
||||
\i 010_chat_notifications.sql
|
||||
|
||||
\echo '=== Running 010_facebook_accounts.sql (Facebook Accounts) ==='
|
||||
\i 010_facebook_accounts.sql
|
||||
|
||||
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
|
||||
\i 011_calendar_events.sql
|
||||
|
||||
\echo '=== Running 012_invites.sql (Invites) ==='
|
||||
\i 012_invites.sql
|
||||
|
||||
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
|
||||
\i 012_sent_emails.sql
|
||||
|
||||
\echo '=== Running 013_security_upgrade.sql (Security Architecture) ==='
|
||||
\i 013_security_upgrade.sql
|
||||
|
||||
\echo '=== Running 013_rls_calendar.sql (Calendar RLS) ==='
|
||||
\i 013_rls_calendar.sql
|
||||
|
||||
\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ==='
|
||||
\i 014_bug_reports.sql
|
||||
|
||||
\echo '=== Running 014_notifications_context.sql (Notifications Context) ==='
|
||||
\i 014_notifications_context.sql
|
||||
|
||||
\echo '=== Running 015_rls_calendar_participant.sql (Calendar RLS Participant Fix) ==='
|
||||
\i 015_rls_calendar_participant.sql
|
||||
|
||||
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
|
||||
\i 016_participant_notes.sql
|
||||
|
||||
\echo '=== Running 017_calendar_developer.sql (Calendar Developer Assignment) ==='
|
||||
\i 017_calendar_developer.sql
|
||||
|
||||
\echo '=== Running 018_website_creation_type.sql (Website Creation Event Type) ==='
|
||||
\i 018_website_creation_type.sql
|
||||
|
||||
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
|
||||
\i 019_allow_null_start_time.sql
|
||||
|
||||
\echo '=== Running 020_fixes.sql (password_change_required + audit trigger fix) ==='
|
||||
\i 020_fixes.sql
|
||||
|
||||
\echo '=== Running 021_performance_indexes.sql (Performance Indexes + RLS Cache) ==='
|
||||
\i 021_performance_indexes.sql
|
||||
|
||||
\echo '=== Migration Complete ==='
|
||||
\echo ''
|
||||
\echo 'Test accounts created:'
|
||||
\echo ' superadmin_demo / SuperAdmin@2026'
|
||||
\echo ' admin_demo / AdminAccess@2026'
|
||||
\echo ' sales_demo / SalesAccess@2026'
|
||||
\echo ' dev_demo / DevTesting@2026'
|
||||
COMMIT;
|
||||
|
||||
+15
-16
@@ -1,19 +1,18 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals.js";
|
||||
import nextTs from "eslint-config-next/typescript.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
ignores: [".next/**", "out/**", "build/**", "next-env.d.ts"],
|
||||
},
|
||||
];
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Scrapers - Configuration & Status
|
||||
|
||||
## Facebook Scraper
|
||||
|
||||
**File:** `rust-ai/src/main.rs`
|
||||
**Lines:** ~70-85
|
||||
|
||||
Target URL (line 72):
|
||||
```rust
|
||||
let url = "https://www.facebook.com/search/top/?q=need%20website%20create";
|
||||
```
|
||||
|
||||
**Status:** Uses direct connection (no proxy) — your home IP. Facebook blocks datacenter IPs. May work from a residential connection.
|
||||
|
||||
**Test with curl:**
|
||||
```
|
||||
curl.exe -s "https://www.facebook.com/search/top/?q=need%20website%20create" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" --max-time 10 2>$null
|
||||
```
|
||||
|
||||
**Expected log output when blocked:**
|
||||
```
|
||||
ERROR crm_ai: Facebook scraper error: error sending request for url (https://www.facebook.com/search/top/?q=need%20website%20create)
|
||||
```
|
||||
|
||||
## Reddit Scraper
|
||||
|
||||
**File:** `rust-ai/src/main.rs`
|
||||
**Lines:** ~90-120
|
||||
|
||||
Uses `old.reddit.com` (older design that allows scraping without captchas).
|
||||
|
||||
Search queries (currently 6, lines 93-100):
|
||||
- `r/southafrica` — "need website", "web developer"
|
||||
- Global — '"need a website"', "website quote"
|
||||
- `r/forhire` — "website"
|
||||
- `r/smallbusiness` — "website"
|
||||
|
||||
**Status:** Working. Reddit results appear as `INFO LEAD:` entries in the server log.
|
||||
|
||||
**Test with curl:**
|
||||
```
|
||||
curl.exe -s "https://old.reddit.com/r/southafrica/search?q=need+website&sort=new&restrict_sr=on&t=week" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
```
|
||||
|
||||
**Expected log output when working:**
|
||||
```
|
||||
INFO crm_ai: LEAD: [Post Title] -> https://old.reddit.com/r/.../...
|
||||
```
|
||||
|
||||
## Background Loop
|
||||
|
||||
**File:** `rust-ai/src/main.rs`
|
||||
**Lines:** ~370-383
|
||||
|
||||
Both scrapers run together every 60-180 seconds on a `spawn_blocking` thread.
|
||||
|
||||
## What You Need to Do
|
||||
|
||||
### Facebook
|
||||
- Try running from your home PC instead — your residential IP may not be blocked
|
||||
- If still blocked, you need residential proxies (BrightData, IPRoyal, Oxylabs)
|
||||
- Configure them in the `PROXIES` array at line 25
|
||||
|
||||
### Reddit
|
||||
- Works out of the box via `old.reddit.com`
|
||||
- If it stops working, Reddit IP-blocked you — use proxies or switch to Playwright
|
||||
|
||||
### To add more search queries
|
||||
Edit the `searches` array in `run_reddit_scraper()` (line 93).
|
||||
|
||||
|
||||
+25
File diff suppressed because one or more lines are too long
+17
-1
@@ -2,7 +2,7 @@ import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
ignoreDuringBuilds: false,
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
@@ -12,6 +12,22 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
...(process.env.NODE_ENV === "production"
|
||||
? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"playwright-mcp.cmd",
|
||||
"--browser",
|
||||
"chrome",
|
||||
"--user-data-dir",
|
||||
"C:\\Users\\Caitlin\\AppData\\Local\\Google\\Chrome\\User Data\\Default",
|
||||
],
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
Generated
+3448
-51
File diff suppressed because it is too large
Load Diff
+37
-9
@@ -3,15 +3,32 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
||||
"dev:signaling": "node signaling-server.mjs",
|
||||
"dev:open": "node scripts/open-browser.mjs",
|
||||
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
||||
"dev:next": "next dev -p 3006",
|
||||
"dev:precheck": "node scripts/precheck.mjs",
|
||||
"dev:ollama": "node scripts/ensure-ollama.mjs",
|
||||
"dev:rust": "node ai-server/index.mjs",
|
||||
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
|
||||
"setup": "node scripts/setup.mjs",
|
||||
"migrate": "node scripts/run-migrations.mjs",
|
||||
"db:migrate": "npm run migrate",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"start": "next start -p 3006",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"ci": "npm run lint && npx tsc --noEmit && npm test",
|
||||
"repair": "echo 'Auto-repair disabled for security. Fix errors manually.'",
|
||||
"repair:watch": "echo 'Auto-repair disabled for security. Fix errors manually.'",
|
||||
"repair:ci": "echo 'Auto-repair disabled for security. Fix errors manually.'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
@@ -26,36 +43,47 @@
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@supabase/ssr": "^0.12.0",
|
||||
"@supabase/supabase-js": "^2.108.2",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"framer-motion": "^11.15.0",
|
||||
"jose": "^6.2.3",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "15.0.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"nodemailer": "^9.0.1",
|
||||
"pg": "^8.21.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"recharts": "^2.15.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@next/swc-win32-x64-msvc": "^15.0.4",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react": "18.3.31",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"concurrently": "^10.0.3",
|
||||
"devenv": "1.0.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.0.4",
|
||||
"maildev": "^2.2.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "C:\\Users\\Hannah Kaur Bagga\\.rustup\\toolchains\\stable-x86_64-pc-windows-gnu\\lib\\rustlib\\x86_64-pc-windows-gnu\\bin\\rust-lld.exe"
|
||||
Generated
+2687
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "crm-ai"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "AI Sales Assistant backend for Coast IT CRM"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "process"] }
|
||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
dotenvy = "0.15"
|
||||
rand = "0.8"
|
||||
jsonwebtoken = "9"
|
||||
@@ -0,0 +1,142 @@
|
||||
# CRM AI Sales Assistant — Self-Knowledge
|
||||
|
||||
## Identity
|
||||
You are the CRM AI Sales Assistant for Coast IT CRM.
|
||||
You run on a Node.js backend (port 3001) and use Ollama with a local model (dolphin3-llama3.2:3b).
|
||||
Your purpose is to help salespeople close more deals by finding and engaging leads.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
|
||||
↓
|
||||
PostgreSQL (conversations)
|
||||
|
||||
Python Scraper (:3008) — Facebook scraping via Playwright
|
||||
```
|
||||
|
||||
Three services run concurrently:
|
||||
- **AI Server** (`ai-server/index.mjs`, port 3001) — chat, setup wizard, config endpoints
|
||||
- **Frontend** (Next.js, port 3006) — UI for salespeople
|
||||
- **Scraper** (`browser-use-service/main.py`, port 3008) — Facebook lead discovery
|
||||
|
||||
## Capabilities
|
||||
- Give sales tips and strategies per job category
|
||||
- Generate cold email and outreach templates
|
||||
- Handle objections with proven rebuttals
|
||||
- Analyse prospect behaviour and suggest next steps
|
||||
- Remember past conversations via PostgreSQL (`ai_conversations` table)
|
||||
- Run Facebook scraper to find real leads asking for services
|
||||
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
|
||||
|
||||
## Facebook Scraper
|
||||
The scraper lives at `browser-use-service/main.py` port 3008.
|
||||
|
||||
### How It Works
|
||||
1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
|
||||
2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
|
||||
3. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
|
||||
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
|
||||
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
|
||||
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
|
||||
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
|
||||
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
|
||||
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
|
||||
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
|
||||
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
|
||||
6. **Stealth mechanics**:
|
||||
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
|
||||
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
|
||||
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
|
||||
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
|
||||
- Random decoy page visits (e.g., Facebook Groups) between searches
|
||||
- Profile directory is temp-copied and cleaned up after each scrape
|
||||
- Detection signal monitoring (checkpoint, login pages, security challenges)
|
||||
7. **2-pass classification (dead-accurate)**:
|
||||
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
|
||||
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
|
||||
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
|
||||
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
|
||||
|
||||
### Lead Categories
|
||||
Two categories, selectable when starting a scrape:
|
||||
|
||||
**Website Creation:**
|
||||
- Target: people explicitly REQUESTING a website built/designed/created for them
|
||||
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
|
||||
- Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
|
||||
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
|
||||
|
||||
**Tutoring:**
|
||||
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
|
||||
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
|
||||
- Request terms: same as website category — must co-occur with a target keyword
|
||||
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
|
||||
|
||||
### Multi-Language Pipeline (Phase Order)
|
||||
4 South African languages in structured phases:
|
||||
- **Phase 1 (English)**: primary query + supplementary English searches
|
||||
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
|
||||
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
|
||||
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
|
||||
|
||||
### Output Format
|
||||
Each lead returned includes:
|
||||
- `title` — post preview text
|
||||
- `author` — poster's name (may include location in name)
|
||||
- `content` — extracted post text
|
||||
- `url` — direct link to the post
|
||||
- `date` — when posted (filtered within 2 days)
|
||||
- `category` — "website" or "tutor"
|
||||
|
||||
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
|
||||
|
||||
### Configuration via Env Vars
|
||||
- `SELECTED_BROWSER` — `firefox` (default), `chrome`, `opera`, `edge`, or `auto`
|
||||
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
|
||||
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
|
||||
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
|
||||
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
|
||||
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
|
||||
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
|
||||
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
|
||||
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
|
||||
|
||||
## How to Start Scraping
|
||||
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
|
||||
2. Open the frontend at `http://localhost:3006`
|
||||
3. Select a job category (Website Creation or Tutoring)
|
||||
4. Click "Search Facebook" — the scraper runs and returns leads
|
||||
5. Leads are saved in the CRM for follow-up
|
||||
|
||||
## Sales Tips
|
||||
- Cold emails should be under 150 words
|
||||
- Follow up within 48 hours
|
||||
- Personalise every outreach with the prospect's name and company
|
||||
- Use open-ended questions in discovery calls
|
||||
- Always ask for the next step before ending a call
|
||||
- For website leads: mention specific pages or features they requested
|
||||
- For tutoring leads: reference the subject and age group they mentioned
|
||||
|
||||
## Job Targeting
|
||||
- Developers respond best to technical value props
|
||||
- Marketing managers care about ROI and metrics
|
||||
- C-level executives want brevity and business impact
|
||||
- Parents hiring tutors: empathy and qualifications matter most
|
||||
|
||||
## Response Rules
|
||||
- Be direct and actionable — no fluff, no AI disclaimers
|
||||
- Use short paragraphs and bullet points
|
||||
- Never mention being an AI or language model
|
||||
- If you don't know something, say so honestly
|
||||
- Prioritise the user's role: salespeople need speed, admins need control
|
||||
- When asked about scraping, give specific guidance on categories and languages
|
||||
|
||||
## Self-Improvement Protocol
|
||||
1. You notice a gap in your knowledge or a pattern in user questions
|
||||
2. You call `POST /ai/instructions` with:
|
||||
- `entry`: description of the improvement
|
||||
- `content`: optional full replacement of ai.md
|
||||
3. The improvement is logged and loaded into the next system prompt
|
||||
|
||||
## Improvement Log
|
||||
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
|
||||
@@ -0,0 +1,671 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, Method, StatusCode},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use tower_http::cors::{CorsLayer, AllowOrigin, Any};
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
use rand::Rng;
|
||||
use chrono::Timelike;
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// ── JWT Claims ────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Claims {
|
||||
#[serde(rename = "userId")]
|
||||
user_id: String,
|
||||
role: String,
|
||||
}
|
||||
|
||||
fn verify_jwt(token: &str, secret: &str) -> Option<Claims> {
|
||||
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
decode::<Claims>(token, &key, &validation).ok().map(|d| d.claims)
|
||||
}
|
||||
|
||||
// ── Rate limiter ──────────────────────────────────────────────
|
||||
|
||||
struct RateLimiter {
|
||||
buckets: Mutex<HashMap<String, Vec<u64>>>,
|
||||
max_requests: usize,
|
||||
window_secs: u64,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
fn new(max_requests: usize, window_secs: u64) -> Self {
|
||||
Self {
|
||||
buckets: Mutex::new(HashMap::new()),
|
||||
max_requests,
|
||||
window_secs,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check(&self, key: &str) -> bool {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
let mut buckets = self.buckets.lock().await;
|
||||
let timestamps = buckets.entry(key.to_string()).or_default();
|
||||
timestamps.retain(|t| now - *t <= self.window_secs);
|
||||
if timestamps.len() >= self.max_requests {
|
||||
false
|
||||
} else {
|
||||
timestamps.push(now);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared state ───────────────────────────────────────────────
|
||||
|
||||
struct AppState {
|
||||
db: sqlx::PgPool,
|
||||
ollama_url: String,
|
||||
model: String,
|
||||
jobs: Vec<Job>,
|
||||
leads: Arc<Mutex<LeadStore>>,
|
||||
http_client: reqwest::Client,
|
||||
jwt_secret: String,
|
||||
rate_limiter: RateLimiter,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct Lead {
|
||||
title: String,
|
||||
url: String,
|
||||
source: String,
|
||||
found_at: u64,
|
||||
author: String,
|
||||
date: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
struct LeadStore {
|
||||
leads: Vec<Lead>,
|
||||
max_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScrapeResponse {
|
||||
success: bool,
|
||||
leads: Vec<ScrapeLead>,
|
||||
flagged: bool,
|
||||
flag_reason: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct ScrapeLead {
|
||||
title: String,
|
||||
url: String,
|
||||
author: String,
|
||||
date: String,
|
||||
content: String,
|
||||
source: Option<String>,
|
||||
}
|
||||
|
||||
impl LeadStore {
|
||||
fn new(max_size: usize) -> Self {
|
||||
Self { leads: Vec::new(), max_size }
|
||||
}
|
||||
|
||||
fn push(&mut self, lead: Lead) {
|
||||
if !self.leads.iter().any(|l| l.url == lead.url) {
|
||||
self.leads.insert(0, lead);
|
||||
self.leads.truncate(self.max_size);
|
||||
}
|
||||
}
|
||||
|
||||
fn recent(&self, max_age_secs: u64, limit: usize) -> Vec<Lead> {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
self.leads.iter()
|
||||
.filter(|l| now.saturating_sub(l.found_at) <= max_age_secs)
|
||||
.take(limit)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Job {
|
||||
job_title: String,
|
||||
keywords: Vec<String>,
|
||||
industry: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatRequest {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChatResponse {
|
||||
response: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JobsResponse {
|
||||
jobs: Vec<Job>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HealthResponse {
|
||||
status: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
// ── Ollama API types ───────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaChatMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaRequest {
|
||||
model: String,
|
||||
messages: Vec<OllamaChatMessage>,
|
||||
stream: bool,
|
||||
options: OllamaOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaOptions {
|
||||
temperature: f32,
|
||||
num_predict: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaResponse {
|
||||
message: Option<OllamaResponseMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaResponseMessage {
|
||||
content: String,
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
s.chars().take(max).collect()
|
||||
}
|
||||
|
||||
fn extract_claims(headers: &HeaderMap, state: &AppState) -> Result<Claims, (StatusCode, String)> {
|
||||
let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()).unwrap_or("");
|
||||
let token = auth_header.strip_prefix("Bearer ").unwrap_or("");
|
||||
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
|
||||
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
|
||||
})?;
|
||||
match claims.role.to_lowercase().as_str() {
|
||||
"sales" | "admin" | "super_admin" => Ok(claims),
|
||||
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_leads_output(leads: &[Lead]) -> String {
|
||||
if leads.is_empty() {
|
||||
return "No new requests found yet.".to_string();
|
||||
}
|
||||
leads
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let author = if l.author.is_empty() { "Unknown" } else { &l.author };
|
||||
let date = truncate(&l.date, 10);
|
||||
format!(
|
||||
"{}. {}\n {}\n {}\n {}",
|
||||
i + 1,
|
||||
author,
|
||||
date,
|
||||
l.title,
|
||||
l.url
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn build_system_prompt(jobs: &[Job], leads: &[Lead]) -> String {
|
||||
let job_list: Vec<String> = jobs
|
||||
.iter()
|
||||
.map(|j| format!("- {} ({}): {}", j.job_title, j.industry, j.description))
|
||||
.collect();
|
||||
let job_list_str = job_list.join("\n");
|
||||
|
||||
let lead_summary: Vec<String> = leads
|
||||
.iter()
|
||||
.map(|l| format!("{} | {} | {}", l.author, l.title, l.url))
|
||||
.collect();
|
||||
let lead_summary_str = if lead_summary.is_empty() {
|
||||
"None yet.".to_string()
|
||||
} else {
|
||||
lead_summary.join("\n")
|
||||
};
|
||||
|
||||
format!(
|
||||
"You are a Sales AI Assistant for Coast IT CRM.\n\n\
|
||||
Available job categories to target:\n{}\n\n\
|
||||
Recent leads context:\n{}\n\n\
|
||||
Rules:\n\
|
||||
- When asked about leads, answer concisely under 150 words.\n\
|
||||
- If asked to suggest a sales strategy, give brief actionable advice.\n\
|
||||
- Be direct and professional. No fluff.",
|
||||
job_list_str, lead_summary_str
|
||||
)
|
||||
}
|
||||
|
||||
// ── Chat handler ───────────────────────────────────────────────
|
||||
|
||||
async fn handle_chat(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ChatRequest>,
|
||||
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
|
||||
let claims = extract_claims(&headers, &state)?;
|
||||
|
||||
if !state.rate_limiter.check(&claims.user_id).await {
|
||||
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
|
||||
}
|
||||
|
||||
let msg_lower = req.message.to_lowercase();
|
||||
let msg_words: Vec<&str> = msg_lower.split_whitespace().collect();
|
||||
let has_listing = msg_words.iter().any(|w| ["listings", "listing", "leads", "links", "lists"].contains(w));
|
||||
let has_show = msg_lower.contains("show me") || msg_lower.contains("give me") || msg_lower.contains("pull");
|
||||
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
|
||||
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
let base_url = "http://localhost:3008/scrape/facebook";
|
||||
use std::fmt::Write;
|
||||
let mut service_url = base_url.to_string();
|
||||
if let Ok(Some((_, path))) = sqlx::query_as::<_, (uuid::Uuid, String)>(
|
||||
"SELECT id, profile_path FROM facebook_accounts \
|
||||
WHERE is_active = TRUE AND flagged = FALSE \
|
||||
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
let encoded: String = path.chars().map(|c| match c {
|
||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' | '~' => c.to_string(),
|
||||
_ => format!("%{:02X}", c as u8),
|
||||
}).collect();
|
||||
write!(service_url, "?profile_path={}&force=true", encoded).unwrap();
|
||||
info!("Calling Python scrape at: {}?profile_path=...&force=true", base_url);
|
||||
} else {
|
||||
warn!("No active Facebook account found for on-demand scrape");
|
||||
}
|
||||
let req_builder = state.http_client.post(&service_url);
|
||||
|
||||
match req_builder.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
info!("Python scrape response ({}): {} bytes", status, body.len());
|
||||
if body.starts_with('{') {
|
||||
match serde_json::from_str::<ScrapeResponse>(&body) {
|
||||
Ok(scrape_resp) => {
|
||||
info!("Scraped {} leads from Facebook", scrape_resp.leads.len());
|
||||
if scrape_resp.leads.is_empty() && scrape_resp.error.is_some() {
|
||||
warn!("Python returned error: {:?}", scrape_resp.error);
|
||||
}
|
||||
let mut store = state.leads.lock().await;
|
||||
for item in &scrape_resp.leads {
|
||||
store.push(Lead {
|
||||
title: truncate(&item.title, 120),
|
||||
url: item.url.clone(),
|
||||
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
|
||||
found_at: now,
|
||||
author: truncate(&item.author, 60),
|
||||
date: truncate(&item.date, 30),
|
||||
content: truncate(&item.content, 300),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse Python scrape response: {} body: {}", e, &body[..body.len().min(200)]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Python returned non-JSON response: {}", &body[..body.len().min(200)]);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Scraper request error: {} - URL: {}", e, base_url);
|
||||
}
|
||||
}
|
||||
|
||||
let recent_leads = state.leads.lock().await.recent(604800, 20);
|
||||
let response = format_leads_output(&recent_leads);
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
|
||||
.bind(&claims.role)
|
||||
.bind(&req.message)
|
||||
.bind(&response)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
return Ok(Json(ChatResponse { response }));
|
||||
}
|
||||
|
||||
let recent_leads = state.leads.lock().await.recent(604800, 20);
|
||||
let system_prompt = build_system_prompt(&state.jobs, &recent_leads);
|
||||
let message_text = req.message.clone();
|
||||
|
||||
let ollama_req = OllamaRequest {
|
||||
model: state.model.clone(),
|
||||
messages: vec![
|
||||
OllamaChatMessage { role: "system".to_string(), content: system_prompt },
|
||||
OllamaChatMessage { role: "user".to_string(), content: req.message.clone() },
|
||||
],
|
||||
stream: false,
|
||||
options: OllamaOptions { temperature: 0.7, num_predict: 1024 },
|
||||
};
|
||||
|
||||
let resp = state.http_client
|
||||
.post(format!("{}/api/chat", state.ollama_url))
|
||||
.json(&ollama_req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Ollama request failed: {}", e);
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "AI service unavailable".to_string())
|
||||
})?;
|
||||
|
||||
let ollama_resp: OllamaResponse = resp.json().await.map_err(|e| {
|
||||
error!("Failed to parse Ollama response: {}", e);
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "AI response parse error".to_string())
|
||||
})?;
|
||||
|
||||
let response_text = ollama_resp.message.map(|m| m.content).unwrap_or_default();
|
||||
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
|
||||
.bind(&claims.role)
|
||||
.bind(&message_text)
|
||||
.bind(&response_text)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
Ok(Json(ChatResponse { response: response_text }))
|
||||
}
|
||||
|
||||
// ── Jobs handler ───────────────────────────────────────────────
|
||||
|
||||
async fn handle_jobs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<JobsResponse>, (StatusCode, String)> {
|
||||
let _claims = extract_claims(&headers, &state)?;
|
||||
if !state.rate_limiter.check(&_claims.user_id).await {
|
||||
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
|
||||
}
|
||||
Ok(Json(JobsResponse { jobs: state.jobs.clone() }))
|
||||
}
|
||||
|
||||
// ── Health handler ─────────────────────────────────────────────
|
||||
|
||||
async fn handle_health(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "ok".to_string(),
|
||||
model: state.model.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "crm_ai=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
|
||||
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
|
||||
|
||||
let jobs_path = std::env::var("JOBS_PATH").unwrap_or_else(|_| "data/ai/jobs.jsonl".to_string());
|
||||
let jobs_content = fs::read_to_string(&jobs_path).unwrap_or_default();
|
||||
let jobs: Vec<Job> = jobs_content.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect();
|
||||
|
||||
info!("Loaded {} job categories, model: {}, Ollama: {}", jobs.len(), model, ollama_url);
|
||||
|
||||
let db = PgPoolOptions::new()
|
||||
.max_connections(20)
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
info!("Connected to PostgreSQL");
|
||||
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client");
|
||||
|
||||
let lead_store = Arc::new(Mutex::new(LeadStore::new(100)));
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db,
|
||||
ollama_url,
|
||||
model,
|
||||
jobs,
|
||||
leads: lead_store.clone(),
|
||||
http_client,
|
||||
jwt_secret,
|
||||
rate_limiter: RateLimiter::new(30, 60),
|
||||
});
|
||||
|
||||
let cors_origins_env = std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "http://localhost:3006,http://127.0.0.1:3006".to_string());
|
||||
let cors_origins: Vec<HeaderValue> = cors_origins_env.split(',')
|
||||
.filter_map(|o| { let t = o.trim(); if t.is_empty() { None } else { t.parse().ok() } })
|
||||
.collect();
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list(cors_origins))
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ai/chat", post(handle_chat))
|
||||
.route("/ai/jobs", get(handle_jobs))
|
||||
.route("/health", get(handle_health))
|
||||
.layer(cors)
|
||||
.with_state(state.clone());
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
info!("CRM AI server listening on {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.expect("Failed to bind address");
|
||||
|
||||
let bg_leads = lead_store.clone();
|
||||
let bg_db = state.db.clone();
|
||||
let bg_url = std::env::var("SCRAPER_URL").unwrap_or_else(|_| "http://localhost:3008".to_string()) + "/scrape/facebook";
|
||||
tokio::spawn(async move {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Failed to build background HTTP client: {} — scraper disabled", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Initial delay to let user on-demand requests take priority
|
||||
tokio::time::sleep(Duration::from_secs(300)).await;
|
||||
|
||||
loop {
|
||||
// 10% random cycle skip — makes pattern non-periodic
|
||||
if rand::thread_rng().gen_range(0..100) < 10 {
|
||||
info!("Skipping this scrape cycle (random 10% skip)");
|
||||
let jitter = rand::thread_rng().gen_range(16200..19800);
|
||||
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip night hours (23:00 – 06:00)
|
||||
let hour = chrono::Local::now().hour();
|
||||
if hour < 6 || hour >= 23 {
|
||||
info!("Night hours ({}) — skipping scrape", hour);
|
||||
let jitter = rand::thread_rng().gen_range(16200..19800);
|
||||
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
|
||||
// Pick next active un-flagged account
|
||||
let account = sqlx::query_as::<_, (uuid::Uuid, String)>(
|
||||
"SELECT id, profile_path FROM facebook_accounts \
|
||||
WHERE is_active = TRUE AND flagged = FALSE \
|
||||
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
|
||||
)
|
||||
.fetch_optional(&bg_db)
|
||||
.await;
|
||||
|
||||
match account {
|
||||
Ok(Some((account_id, profile_path))) => {
|
||||
match client.post(&bg_url).query(&[("profile_path", &profile_path)]).send().await {
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
match resp.json::<ScrapeResponse>().await {
|
||||
Ok(data) => {
|
||||
let leads_count = data.leads.len() as i32;
|
||||
if data.flagged {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE facebook_accounts SET flagged = TRUE, flagged_at = NOW(), \
|
||||
flagged_reason = $2, last_error_at = NOW(), \
|
||||
last_error_message = $3, consecutive_failures = consecutive_failures + 1 \
|
||||
WHERE id = $1"
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(&data.flag_reason)
|
||||
.bind(&data.error)
|
||||
.execute(&bg_db)
|
||||
.await;
|
||||
warn!("Facebook account {} flagged: {:?}", account_id, data.flag_reason);
|
||||
let reason = data.flag_reason.as_deref().unwrap_or("unknown");
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO notifications (user_id, type, title, description, link) \
|
||||
SELECT id, 'warning', 'Facebook Account Flagged', \
|
||||
$1 || ' - ' || COALESCE($2, 'unknown reason'), \
|
||||
NULL \
|
||||
FROM users u JOIN user_roles ur ON ur.user_id = u.id \
|
||||
JOIN roles r ON r.id = ur.role_id \
|
||||
WHERE r.name IN ('ADMIN', 'SUPER_ADMIN')"
|
||||
)
|
||||
.bind(&account_id.to_string())
|
||||
.bind(reason)
|
||||
.execute(&bg_db)
|
||||
.await;
|
||||
} else if data.success {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE facebook_accounts SET last_scrape_at = NOW(), \
|
||||
last_success_at = NOW(), consecutive_failures = 0, \
|
||||
updated_at = NOW() WHERE id = $1"
|
||||
)
|
||||
.bind(account_id)
|
||||
.execute(&bg_db)
|
||||
.await;
|
||||
|
||||
let mut store = bg_leads.lock().await;
|
||||
for item in &data.leads {
|
||||
store.push(Lead {
|
||||
title: truncate(&item.title, 120),
|
||||
url: item.url.clone(),
|
||||
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
|
||||
found_at: now,
|
||||
author: truncate(&item.author, 60),
|
||||
date: truncate(&item.date, 30),
|
||||
content: truncate(&item.content, 300),
|
||||
});
|
||||
}
|
||||
info!("Scraped {} leads from Facebook account {}", leads_count, account_id);
|
||||
} else {
|
||||
// Increment failures; auto-flag if >= 3 consecutive
|
||||
let _ = sqlx::query(
|
||||
"UPDATE facebook_accounts SET last_error_at = NOW(), \
|
||||
last_error_message = $2, consecutive_failures = consecutive_failures + 1, \
|
||||
flagged = CASE WHEN consecutive_failures + 1 >= 3 THEN TRUE ELSE flagged END, \
|
||||
flagged_reason = CASE WHEN consecutive_failures + 1 >= 3 THEN 'too_many_failures' ELSE flagged_reason END, \
|
||||
flagged_at = CASE WHEN consecutive_failures + 1 >= 3 THEN NOW() ELSE flagged_at END, \
|
||||
updated_at = NOW() WHERE id = $1"
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(&data.error)
|
||||
.execute(&bg_db)
|
||||
.await;
|
||||
warn!("Facebook scrape failed for account {}: {:?}", account_id, data.error);
|
||||
}
|
||||
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO facebook_scrape_logs \
|
||||
(account_id, started_at, completed_at, success, leads_found, error_message, detected_flag) \
|
||||
VALUES ($1, NOW() - interval '5 hours', NOW(), $2, $3, $4, $5)"
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(data.success && !data.flagged)
|
||||
.bind(leads_count)
|
||||
.bind(&data.error)
|
||||
.bind(&data.flag_reason)
|
||||
.execute(&bg_db)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse scraper JSON: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Scraper returned status: {}", resp.status());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Scraper request failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("No active Facebook accounts available — skipping scrape cycle");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to query Facebook accounts: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let jitter = rand::thread_rng().gen_range(16200..19800); // 4.5h – 5.5h
|
||||
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||
}
|
||||
});
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("Server failed");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
const express = require('express');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const PORT = process.env.PORT || 3007;
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.post('/scrape/indeed', async (req, res) => {
|
||||
const results = [];
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
});
|
||||
|
||||
const queries = [
|
||||
'need a website built',
|
||||
'looking for someone to build my website',
|
||||
'need web designer',
|
||||
'looking for web developer',
|
||||
'need help with my website',
|
||||
'need ecommerce website',
|
||||
'need wordpress website',
|
||||
'looking for website designer',
|
||||
];
|
||||
|
||||
for (const q of queries) {
|
||||
const url = `https://www.indeed.com/jobs?q=${encodeURIComponent(q)}&sort=date`;
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const items = await page.evaluate(() => {
|
||||
const cards = document.querySelectorAll('.job_seen_beacon');
|
||||
return Array.from(cards).slice(0, 8).map(card => {
|
||||
const titleEl = card.querySelector('.jcs-JobTitle');
|
||||
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"]');
|
||||
const snippetEl = card.querySelector('.job-snippet');
|
||||
return {
|
||||
title: titleEl?.textContent?.trim() || '',
|
||||
url: titleEl?.href || '',
|
||||
company: companyEl?.textContent?.trim() || '',
|
||||
snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '',
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
if (item.title && item.url && !results.some(r => r.url === item.url)) {
|
||||
results.push({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
author: item.company,
|
||||
date: '',
|
||||
content: item.snippet,
|
||||
source: 'indeed',
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.close();
|
||||
} catch (e) {
|
||||
console.error(`Indeed failed ${q}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
res.json(results.slice(0, 50));
|
||||
} catch (e) {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
console.error('Indeed error:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/scrape/all', async (req, res) => {
|
||||
try {
|
||||
const [indeed] = await Promise.allSettled([
|
||||
fetch(`http://localhost:${PORT}/scrape/indeed`, { method: 'POST' }).then(r => r.json()).catch(() => []),
|
||||
]);
|
||||
|
||||
const all = [
|
||||
...(indeed.status === 'fulfilled' ? indeed.value : []),
|
||||
];
|
||||
console.log(`Scrape all: ${all.length} leads`);
|
||||
res.json(all);
|
||||
} catch (e) {
|
||||
console.error('Scrape all error:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Scraper service running on port ${PORT}`);
|
||||
});
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../mime/cli.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../mime/cli.js" "$@"
|
||||
fi
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\mime\cli.js" %*
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../mime/cli.js" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../mime/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../mime/cli.js" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../mime/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../playwright/cli.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../playwright/cli.js" "$@"
|
||||
fi
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../playwright-core/cli.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../playwright-core/cli.js" "$@"
|
||||
fi
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\playwright-core\cli.js" %*
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../playwright-core/cli.js" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../playwright-core/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../playwright-core/cli.js" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../playwright-core/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\playwright\cli.js" %*
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../playwright/cli.js" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../playwright/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../playwright/cli.js" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../playwright/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
+850
@@ -0,0 +1,850 @@
|
||||
{
|
||||
"name": "scraper-service",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
1.3.8 / 2022-02-02
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.1.34
|
||||
- deps: mime-db@~1.51.0
|
||||
* deps: negotiator@0.6.3
|
||||
|
||||
1.3.7 / 2019-04-29
|
||||
==================
|
||||
|
||||
* deps: negotiator@0.6.2
|
||||
- Fix sorting charset, encoding, and language with extra parameters
|
||||
|
||||
1.3.6 / 2019-04-28
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.1.24
|
||||
- deps: mime-db@~1.40.0
|
||||
|
||||
1.3.5 / 2018-02-28
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.1.18
|
||||
- deps: mime-db@~1.33.0
|
||||
|
||||
1.3.4 / 2017-08-22
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.1.16
|
||||
- deps: mime-db@~1.29.0
|
||||
|
||||
1.3.3 / 2016-05-02
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.1.11
|
||||
- deps: mime-db@~1.23.0
|
||||
* deps: negotiator@0.6.1
|
||||
- perf: improve `Accept` parsing speed
|
||||
- perf: improve `Accept-Charset` parsing speed
|
||||
- perf: improve `Accept-Encoding` parsing speed
|
||||
- perf: improve `Accept-Language` parsing speed
|
||||
|
||||
1.3.2 / 2016-03-08
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.1.10
|
||||
- Fix extension of `application/dash+xml`
|
||||
- Update primary extension for `audio/mp4`
|
||||
- deps: mime-db@~1.22.0
|
||||
|
||||
1.3.1 / 2016-01-19
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.1.9
|
||||
- deps: mime-db@~1.21.0
|
||||
|
||||
1.3.0 / 2015-09-29
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.1.7
|
||||
- deps: mime-db@~1.19.0
|
||||
* deps: negotiator@0.6.0
|
||||
- Fix including type extensions in parameters in `Accept` parsing
|
||||
- Fix parsing `Accept` parameters with quoted equals
|
||||
- Fix parsing `Accept` parameters with quoted semicolons
|
||||
- Lazy-load modules from main entry point
|
||||
- perf: delay type concatenation until needed
|
||||
- perf: enable strict mode
|
||||
- perf: hoist regular expressions
|
||||
- perf: remove closures getting spec properties
|
||||
- perf: remove a closure from media type parsing
|
||||
- perf: remove property delete from media type parsing
|
||||
|
||||
1.2.13 / 2015-09-06
|
||||
===================
|
||||
|
||||
* deps: mime-types@~2.1.6
|
||||
- deps: mime-db@~1.18.0
|
||||
|
||||
1.2.12 / 2015-07-30
|
||||
===================
|
||||
|
||||
* deps: mime-types@~2.1.4
|
||||
- deps: mime-db@~1.16.0
|
||||
|
||||
1.2.11 / 2015-07-16
|
||||
===================
|
||||
|
||||
* deps: mime-types@~2.1.3
|
||||
- deps: mime-db@~1.15.0
|
||||
|
||||
1.2.10 / 2015-07-01
|
||||
===================
|
||||
|
||||
* deps: mime-types@~2.1.2
|
||||
- deps: mime-db@~1.14.0
|
||||
|
||||
1.2.9 / 2015-06-08
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.1.1
|
||||
- perf: fix deopt during mapping
|
||||
|
||||
1.2.8 / 2015-06-07
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.1.0
|
||||
- deps: mime-db@~1.13.0
|
||||
* perf: avoid argument reassignment & argument slice
|
||||
* perf: avoid negotiator recursive construction
|
||||
* perf: enable strict mode
|
||||
* perf: remove unnecessary bitwise operator
|
||||
|
||||
1.2.7 / 2015-05-10
|
||||
==================
|
||||
|
||||
* deps: negotiator@0.5.3
|
||||
- Fix media type parameter matching to be case-insensitive
|
||||
|
||||
1.2.6 / 2015-05-07
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.0.11
|
||||
- deps: mime-db@~1.9.1
|
||||
* deps: negotiator@0.5.2
|
||||
- Fix comparing media types with quoted values
|
||||
- Fix splitting media types with quoted commas
|
||||
|
||||
1.2.5 / 2015-03-13
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.0.10
|
||||
- deps: mime-db@~1.8.0
|
||||
|
||||
1.2.4 / 2015-02-14
|
||||
==================
|
||||
|
||||
* Support Node.js 0.6
|
||||
* deps: mime-types@~2.0.9
|
||||
- deps: mime-db@~1.7.0
|
||||
* deps: negotiator@0.5.1
|
||||
- Fix preference sorting to be stable for long acceptable lists
|
||||
|
||||
1.2.3 / 2015-01-31
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.0.8
|
||||
- deps: mime-db@~1.6.0
|
||||
|
||||
1.2.2 / 2014-12-30
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.0.7
|
||||
- deps: mime-db@~1.5.0
|
||||
|
||||
1.2.1 / 2014-12-30
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.0.5
|
||||
- deps: mime-db@~1.3.1
|
||||
|
||||
1.2.0 / 2014-12-19
|
||||
==================
|
||||
|
||||
* deps: negotiator@0.5.0
|
||||
- Fix list return order when large accepted list
|
||||
- Fix missing identity encoding when q=0 exists
|
||||
- Remove dynamic building of Negotiator class
|
||||
|
||||
1.1.4 / 2014-12-10
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.0.4
|
||||
- deps: mime-db@~1.3.0
|
||||
|
||||
1.1.3 / 2014-11-09
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.0.3
|
||||
- deps: mime-db@~1.2.0
|
||||
|
||||
1.1.2 / 2014-10-14
|
||||
==================
|
||||
|
||||
* deps: negotiator@0.4.9
|
||||
- Fix error when media type has invalid parameter
|
||||
|
||||
1.1.1 / 2014-09-28
|
||||
==================
|
||||
|
||||
* deps: mime-types@~2.0.2
|
||||
- deps: mime-db@~1.1.0
|
||||
* deps: negotiator@0.4.8
|
||||
- Fix all negotiations to be case-insensitive
|
||||
- Stable sort preferences of same quality according to client order
|
||||
|
||||
1.1.0 / 2014-09-02
|
||||
==================
|
||||
|
||||
* update `mime-types`
|
||||
|
||||
1.0.7 / 2014-07-04
|
||||
==================
|
||||
|
||||
* Fix wrong type returned from `type` when match after unknown extension
|
||||
|
||||
1.0.6 / 2014-06-24
|
||||
==================
|
||||
|
||||
* deps: negotiator@0.4.7
|
||||
|
||||
1.0.5 / 2014-06-20
|
||||
==================
|
||||
|
||||
* fix crash when unknown extension given
|
||||
|
||||
1.0.4 / 2014-06-19
|
||||
==================
|
||||
|
||||
* use `mime-types`
|
||||
|
||||
1.0.3 / 2014-06-11
|
||||
==================
|
||||
|
||||
* deps: negotiator@0.4.6
|
||||
- Order by specificity when quality is the same
|
||||
|
||||
1.0.2 / 2014-05-29
|
||||
==================
|
||||
|
||||
* Fix interpretation when header not in request
|
||||
* deps: pin negotiator@0.4.5
|
||||
|
||||
1.0.1 / 2014-01-18
|
||||
==================
|
||||
|
||||
* Identity encoding isn't always acceptable
|
||||
* deps: negotiator@~0.4.0
|
||||
|
||||
1.0.0 / 2013-12-27
|
||||
==================
|
||||
|
||||
* Genesis
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
(The MIT License)
|
||||
|
||||
Copyright (c) 2014 Jonathan Ong <me@jongleberry.com>
|
||||
Copyright (c) 2015 Douglas Christopher Wilson <doug@somethingdoug.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
'Software'), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
# accepts
|
||||
|
||||
[![NPM Version][npm-version-image]][npm-url]
|
||||
[![NPM Downloads][npm-downloads-image]][npm-url]
|
||||
[![Node.js Version][node-version-image]][node-version-url]
|
||||
[![Build Status][github-actions-ci-image]][github-actions-ci-url]
|
||||
[![Test Coverage][coveralls-image]][coveralls-url]
|
||||
|
||||
Higher level content negotiation based on [negotiator](https://www.npmjs.com/package/negotiator).
|
||||
Extracted from [koa](https://www.npmjs.com/package/koa) for general use.
|
||||
|
||||
In addition to negotiator, it allows:
|
||||
|
||||
- Allows types as an array or arguments list, ie `(['text/html', 'application/json'])`
|
||||
as well as `('text/html', 'application/json')`.
|
||||
- Allows type shorthands such as `json`.
|
||||
- Returns `false` when no types match
|
||||
- Treats non-existent headers as `*`
|
||||
|
||||
## Installation
|
||||
|
||||
This is a [Node.js](https://nodejs.org/en/) module available through the
|
||||
[npm registry](https://www.npmjs.com/). Installation is done using the
|
||||
[`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally):
|
||||
|
||||
```sh
|
||||
$ npm install accepts
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
var accepts = require('accepts')
|
||||
```
|
||||
|
||||
### accepts(req)
|
||||
|
||||
Create a new `Accepts` object for the given `req`.
|
||||
|
||||
#### .charset(charsets)
|
||||
|
||||
Return the first accepted charset. If nothing in `charsets` is accepted,
|
||||
then `false` is returned.
|
||||
|
||||
#### .charsets()
|
||||
|
||||
Return the charsets that the request accepts, in the order of the client's
|
||||
preference (most preferred first).
|
||||
|
||||
#### .encoding(encodings)
|
||||
|
||||
Return the first accepted encoding. If nothing in `encodings` is accepted,
|
||||
then `false` is returned.
|
||||
|
||||
#### .encodings()
|
||||
|
||||
Return the encodings that the request accepts, in the order of the client's
|
||||
preference (most preferred first).
|
||||
|
||||
#### .language(languages)
|
||||
|
||||
Return the first accepted language. If nothing in `languages` is accepted,
|
||||
then `false` is returned.
|
||||
|
||||
#### .languages()
|
||||
|
||||
Return the languages that the request accepts, in the order of the client's
|
||||
preference (most preferred first).
|
||||
|
||||
#### .type(types)
|
||||
|
||||
Return the first accepted type (and it is returned as the same text as what
|
||||
appears in the `types` array). If nothing in `types` is accepted, then `false`
|
||||
is returned.
|
||||
|
||||
The `types` array can contain full MIME types or file extensions. Any value
|
||||
that is not a full MIME types is passed to `require('mime-types').lookup`.
|
||||
|
||||
#### .types()
|
||||
|
||||
Return the types that the request accepts, in the order of the client's
|
||||
preference (most preferred first).
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple type negotiation
|
||||
|
||||
This simple example shows how to use `accepts` to return a different typed
|
||||
respond body based on what the client wants to accept. The server lists it's
|
||||
preferences in order and will get back the best match between the client and
|
||||
server.
|
||||
|
||||
```js
|
||||
var accepts = require('accepts')
|
||||
var http = require('http')
|
||||
|
||||
function app (req, res) {
|
||||
var accept = accepts(req)
|
||||
|
||||
// the order of this list is significant; should be server preferred order
|
||||
switch (accept.type(['json', 'html'])) {
|
||||
case 'json':
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.write('{"hello":"world!"}')
|
||||
break
|
||||
case 'html':
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.write('<b>hello, world!</b>')
|
||||
break
|
||||
default:
|
||||
// the fallback is text/plain, so no need to specify it above
|
||||
res.setHeader('Content-Type', 'text/plain')
|
||||
res.write('hello, world!')
|
||||
break
|
||||
}
|
||||
|
||||
res.end()
|
||||
}
|
||||
|
||||
http.createServer(app).listen(3000)
|
||||
```
|
||||
|
||||
You can test this out with the cURL program:
|
||||
```sh
|
||||
curl -I -H'Accept: text/html' http://localhost:3000/
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
[coveralls-image]: https://badgen.net/coveralls/c/github/jshttp/accepts/master
|
||||
[coveralls-url]: https://coveralls.io/r/jshttp/accepts?branch=master
|
||||
[github-actions-ci-image]: https://badgen.net/github/checks/jshttp/accepts/master?label=ci
|
||||
[github-actions-ci-url]: https://github.com/jshttp/accepts/actions/workflows/ci.yml
|
||||
[node-version-image]: https://badgen.net/npm/node/accepts
|
||||
[node-version-url]: https://nodejs.org/en/download
|
||||
[npm-downloads-image]: https://badgen.net/npm/dm/accepts
|
||||
[npm-url]: https://npmjs.org/package/accepts
|
||||
[npm-version-image]: https://badgen.net/npm/v/accepts
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
/*!
|
||||
* accepts
|
||||
* Copyright(c) 2014 Jonathan Ong
|
||||
* Copyright(c) 2015 Douglas Christopher Wilson
|
||||
* MIT Licensed
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
* @private
|
||||
*/
|
||||
|
||||
var Negotiator = require('negotiator')
|
||||
var mime = require('mime-types')
|
||||
|
||||
/**
|
||||
* Module exports.
|
||||
* @public
|
||||
*/
|
||||
|
||||
module.exports = Accepts
|
||||
|
||||
/**
|
||||
* Create a new Accepts object for the given req.
|
||||
*
|
||||
* @param {object} req
|
||||
* @public
|
||||
*/
|
||||
|
||||
function Accepts (req) {
|
||||
if (!(this instanceof Accepts)) {
|
||||
return new Accepts(req)
|
||||
}
|
||||
|
||||
this.headers = req.headers
|
||||
this.negotiator = new Negotiator(req)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given `type(s)` is acceptable, returning
|
||||
* the best match when true, otherwise `undefined`, in which
|
||||
* case you should respond with 406 "Not Acceptable".
|
||||
*
|
||||
* The `type` value may be a single mime type string
|
||||
* such as "application/json", the extension name
|
||||
* such as "json" or an array `["json", "html", "text/plain"]`. When a list
|
||||
* or array is given the _best_ match, if any is returned.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* // Accept: text/html
|
||||
* this.types('html');
|
||||
* // => "html"
|
||||
*
|
||||
* // Accept: text/*, application/json
|
||||
* this.types('html');
|
||||
* // => "html"
|
||||
* this.types('text/html');
|
||||
* // => "text/html"
|
||||
* this.types('json', 'text');
|
||||
* // => "json"
|
||||
* this.types('application/json');
|
||||
* // => "application/json"
|
||||
*
|
||||
* // Accept: text/*, application/json
|
||||
* this.types('image/png');
|
||||
* this.types('png');
|
||||
* // => undefined
|
||||
*
|
||||
* // Accept: text/*;q=.5, application/json
|
||||
* this.types(['html', 'json']);
|
||||
* this.types('html', 'json');
|
||||
* // => "json"
|
||||
*
|
||||
* @param {String|Array} types...
|
||||
* @return {String|Array|Boolean}
|
||||
* @public
|
||||
*/
|
||||
|
||||
Accepts.prototype.type =
|
||||
Accepts.prototype.types = function (types_) {
|
||||
var types = types_
|
||||
|
||||
// support flattened arguments
|
||||
if (types && !Array.isArray(types)) {
|
||||
types = new Array(arguments.length)
|
||||
for (var i = 0; i < types.length; i++) {
|
||||
types[i] = arguments[i]
|
||||
}
|
||||
}
|
||||
|
||||
// no types, return all requested types
|
||||
if (!types || types.length === 0) {
|
||||
return this.negotiator.mediaTypes()
|
||||
}
|
||||
|
||||
// no accept header, return first given type
|
||||
if (!this.headers.accept) {
|
||||
return types[0]
|
||||
}
|
||||
|
||||
var mimes = types.map(extToMime)
|
||||
var accepts = this.negotiator.mediaTypes(mimes.filter(validMime))
|
||||
var first = accepts[0]
|
||||
|
||||
return first
|
||||
? types[mimes.indexOf(first)]
|
||||
: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Return accepted encodings or best fit based on `encodings`.
|
||||
*
|
||||
* Given `Accept-Encoding: gzip, deflate`
|
||||
* an array sorted by quality is returned:
|
||||
*
|
||||
* ['gzip', 'deflate']
|
||||
*
|
||||
* @param {String|Array} encodings...
|
||||
* @return {String|Array}
|
||||
* @public
|
||||
*/
|
||||
|
||||
Accepts.prototype.encoding =
|
||||
Accepts.prototype.encodings = function (encodings_) {
|
||||
var encodings = encodings_
|
||||
|
||||
// support flattened arguments
|
||||
if (encodings && !Array.isArray(encodings)) {
|
||||
encodings = new Array(arguments.length)
|
||||
for (var i = 0; i < encodings.length; i++) {
|
||||
encodings[i] = arguments[i]
|
||||
}
|
||||
}
|
||||
|
||||
// no encodings, return all requested encodings
|
||||
if (!encodings || encodings.length === 0) {
|
||||
return this.negotiator.encodings()
|
||||
}
|
||||
|
||||
return this.negotiator.encodings(encodings)[0] || false
|
||||
}
|
||||
|
||||
/**
|
||||
* Return accepted charsets or best fit based on `charsets`.
|
||||
*
|
||||
* Given `Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5`
|
||||
* an array sorted by quality is returned:
|
||||
*
|
||||
* ['utf-8', 'utf-7', 'iso-8859-1']
|
||||
*
|
||||
* @param {String|Array} charsets...
|
||||
* @return {String|Array}
|
||||
* @public
|
||||
*/
|
||||
|
||||
Accepts.prototype.charset =
|
||||
Accepts.prototype.charsets = function (charsets_) {
|
||||
var charsets = charsets_
|
||||
|
||||
// support flattened arguments
|
||||
if (charsets && !Array.isArray(charsets)) {
|
||||
charsets = new Array(arguments.length)
|
||||
for (var i = 0; i < charsets.length; i++) {
|
||||
charsets[i] = arguments[i]
|
||||
}
|
||||
}
|
||||
|
||||
// no charsets, return all requested charsets
|
||||
if (!charsets || charsets.length === 0) {
|
||||
return this.negotiator.charsets()
|
||||
}
|
||||
|
||||
return this.negotiator.charsets(charsets)[0] || false
|
||||
}
|
||||
|
||||
/**
|
||||
* Return accepted languages or best fit based on `langs`.
|
||||
*
|
||||
* Given `Accept-Language: en;q=0.8, es, pt`
|
||||
* an array sorted by quality is returned:
|
||||
*
|
||||
* ['es', 'pt', 'en']
|
||||
*
|
||||
* @param {String|Array} langs...
|
||||
* @return {Array|String}
|
||||
* @public
|
||||
*/
|
||||
|
||||
Accepts.prototype.lang =
|
||||
Accepts.prototype.langs =
|
||||
Accepts.prototype.language =
|
||||
Accepts.prototype.languages = function (languages_) {
|
||||
var languages = languages_
|
||||
|
||||
// support flattened arguments
|
||||
if (languages && !Array.isArray(languages)) {
|
||||
languages = new Array(arguments.length)
|
||||
for (var i = 0; i < languages.length; i++) {
|
||||
languages[i] = arguments[i]
|
||||
}
|
||||
}
|
||||
|
||||
// no languages, return all requested languages
|
||||
if (!languages || languages.length === 0) {
|
||||
return this.negotiator.languages()
|
||||
}
|
||||
|
||||
return this.negotiator.languages(languages)[0] || false
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert extnames to mime.
|
||||
*
|
||||
* @param {String} type
|
||||
* @return {String}
|
||||
* @private
|
||||
*/
|
||||
|
||||
function extToMime (type) {
|
||||
return type.indexOf('/') === -1
|
||||
? mime.lookup(type)
|
||||
: type
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mime is valid.
|
||||
*
|
||||
* @param {String} type
|
||||
* @return {String}
|
||||
* @private
|
||||
*/
|
||||
|
||||
function validMime (type) {
|
||||
return typeof type === 'string'
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "accepts",
|
||||
"description": "Higher-level content negotiation",
|
||||
"version": "1.3.8",
|
||||
"contributors": [
|
||||
"Douglas Christopher Wilson <doug@somethingdoug.com>",
|
||||
"Jonathan Ong <me@jongleberry.com> (http://jongleberry.com)"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": "jshttp/accepts",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"deep-equal": "1.0.1",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-config-standard": "14.1.1",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-markdown": "2.2.1",
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"eslint-plugin-promise": "4.3.1",
|
||||
"eslint-plugin-standard": "4.1.0",
|
||||
"mocha": "9.2.0",
|
||||
"nyc": "15.1.0"
|
||||
},
|
||||
"files": [
|
||||
"LICENSE",
|
||||
"HISTORY.md",
|
||||
"index.js"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"test": "mocha --reporter spec --check-leaks --bail test/",
|
||||
"test-ci": "nyc --reporter=lcov --reporter=text npm test",
|
||||
"test-cov": "nyc --reporter=html --reporter=text npm test"
|
||||
},
|
||||
"keywords": [
|
||||
"content",
|
||||
"negotiation",
|
||||
"accept",
|
||||
"accepts"
|
||||
]
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Array Flatten
|
||||
|
||||
[![NPM version][npm-image]][npm-url]
|
||||
[![NPM downloads][downloads-image]][downloads-url]
|
||||
[![Build status][travis-image]][travis-url]
|
||||
[![Test coverage][coveralls-image]][coveralls-url]
|
||||
|
||||
> Flatten an array of nested arrays into a single flat array. Accepts an optional depth.
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
npm install array-flatten --save
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```javascript
|
||||
var flatten = require('array-flatten')
|
||||
|
||||
flatten([1, [2, [3, [4, [5], 6], 7], 8], 9])
|
||||
//=> [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
|
||||
flatten([1, [2, [3, [4, [5], 6], 7], 8], 9], 2)
|
||||
//=> [1, 2, 3, [4, [5], 6], 7, 8, 9]
|
||||
|
||||
(function () {
|
||||
flatten(arguments) //=> [1, 2, 3]
|
||||
})(1, [2, 3])
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
[npm-image]: https://img.shields.io/npm/v/array-flatten.svg?style=flat
|
||||
[npm-url]: https://npmjs.org/package/array-flatten
|
||||
[downloads-image]: https://img.shields.io/npm/dm/array-flatten.svg?style=flat
|
||||
[downloads-url]: https://npmjs.org/package/array-flatten
|
||||
[travis-image]: https://img.shields.io/travis/blakeembrey/array-flatten.svg?style=flat
|
||||
[travis-url]: https://travis-ci.org/blakeembrey/array-flatten
|
||||
[coveralls-image]: https://img.shields.io/coveralls/blakeembrey/array-flatten.svg?style=flat
|
||||
[coveralls-url]: https://coveralls.io/r/blakeembrey/array-flatten?branch=master
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user