Compare commits

..

57 Commits

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

Fixes: double-login bug, session persistence across restarts, blank page on
session expiry
2026-06-26 21:29:36 +02:00
Hannah_Bagga fa97abb5b6 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 20:59:21 +02:00
Hannah_Bagga 3998285fd5 Fixed the gif picker just need to add in gifs 2026-06-26 20:58:57 +02:00
Hannah_Bagga 4c1db42873 Chat page fits whole screen now 2026-06-26 20:46:20 +02:00
Hannah_Bagga c428435f2f Removed video call, fixed logo, CRM changes with color theme 2026-06-26 20:30:46 +02:00
caitlin 886a7efe91 Ficed the leads per month in dev account 2026-06-26 19:52:02 +02:00
TroodonEnjoyer 1b5f244f28 Login possible fix and something else I can't remember 2026-06-26 19:39:45 +02:00
Hannah_Bagga 46a3216386 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:34:37 +02:00
Ace 8aba5c9ef4 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:34:05 +02:00
Ace 06181625c3 AI back please test out and tell me added, filter words for AI! 2026-06-26 19:31:07 +02:00
Hannah_Bagga a876f2d769 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:23:02 +02:00
Hannah_Bagga 214c4a1852 Spiderman Theme Fixed 2026-06-26 19:22:54 +02:00
212 changed files with 10606 additions and 1648 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.git
*.md
.vscode
__pycache__
*.pyc
.DS_Store
.env.local
.env
.gitignore
.prettierrc
+58
View File
@@ -0,0 +1,58 @@
name: Build & Auto-Repair
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Install Playwright
run: npx playwright install chromium firefox
- name: Build check
id: build
continue-on-error: true
run: npx tsc --noEmit
- name: Run self-healing setup
if: steps.build.outcome == 'failure'
run: node scripts/setup.mjs --self-heal
- name: Run code repair agent
if: steps.build.outcome == 'failure'
env:
OLLAMA_HOST: ${{ vars.OLLAMA_HOST || 'http://localhost:11434' }}
run: node scripts/code-repair-agent.mjs --ci
- name: Re-check build after repair
id: rebuild
continue-on-error: true
run: npx tsc --noEmit
- name: Commit fixes
if: steps.build.outcome == 'failure' && steps.rebuild.outcome == 'success'
run: |
git config user.name "CRM Repair Bot"
git config user.email "bot@coastit.co.za"
git add -A
git diff --cached --quiet || git commit -m "[bot] Auto-repair build errors"
git push
- name: Build failed — report
if: steps.rebuild.outcome == 'failure'
run: |
echo "::error::Build still failing after auto-repair. Manual intervention required."
exit 1
+42
View File
@@ -0,0 +1,42 @@
{
"version": 2,
"description": "Template registry for self-healing setup. Maps internal import paths to templates.",
"templates": {
"data/stickers": {
"template": "stickers.ts",
"description": "Emoji-based sticker packs for media picker"
},
"data/constants": {
"template": "constants.ts",
"description": "App-wide constants (lead statuses, roles, event types)"
},
"lib/utils": {
"template": "utils.ts",
"description": "Utility functions (cn, formatters, helpers)"
},
"types/index": {
"template": "types-index.ts",
"description": "Core type definitions (User, Lead, DashboardStats)"
},
"hooks/use-media": {
"template": "hooks/use-media.ts",
"description": "Media query hook"
},
"hooks/use-debounce": {
"template": "hooks/use-debounce.ts",
"description": "Debounce hook"
},
"hooks/use-local-storage": {
"template": "hooks/use-local-storage.ts",
"description": "LocalStorage hook with SSR safety"
}
},
"fallbackTemplates": {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts"
}
}
@@ -0,0 +1,7 @@
// ── Generic Component Template ────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/components/* imports. Customize as needed.
export default function GenericComponent() {
return <div />;
}
+54
View File
@@ -0,0 +1,54 @@
// ── App Constants ────────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/constants.ts to customize.
export const APP_NAME = "CoastIT CRM";
export const APP_VERSION = "1.0.0";
export const LEAD_STATUSES = {
new: { label: "New", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-yellow-500" },
qualified: { label: "Qualified", color: "bg-purple-500" },
proposal: { label: "Proposal", color: "bg-orange-500" },
won: { label: "Won", color: "bg-green-500" },
lost: { label: "Lost", color: "bg-red-500" },
} as const;
export const USER_ROLES = {
super_admin: { label: "Super Admin", level: 1 },
admin: { label: "Admin", level: 2 },
sales: { label: "Sales", level: 3 },
dev: { label: "Developer", level: 4 },
} as const;
export const EVENT_TYPES = [
"meeting",
"call",
"email",
"task",
"note",
] as const;
export const EVENT_STATUSES = [
"scheduled",
"completed",
"cancelled",
"rescheduled",
] as const;
export const AI_MODELS = {
chat: "llama3.2:3b",
scraper: "dolphin-llama3:8b",
coder: "qwen2.5-coder:1.5b-base",
} as const;
export const PAGINATION = {
defaultLimit: 50,
maxLimit: 200,
} as const;
export const DATE_FORMATS = {
short: "MMM d, yyyy",
long: "MMMM d, yyyy",
time: "h:mm a",
datetime: "MMM d, yyyy h:mm a",
} as const;
@@ -0,0 +1,17 @@
// ── Generic Data Module Template ─────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/data/* imports. Customize as needed.
export interface DataItem {
id: string;
name: string;
[key: string]: unknown;
}
export function getDataItems(): DataItem[] {
return [];
}
export function getDataItem(id: string): DataItem | undefined {
return undefined;
}
@@ -0,0 +1,10 @@
// ── Generic Hook Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/hooks/* imports. Customize as needed.
import { useState, useEffect } from "react";
export function useHook<T>(initialValue: T): [T, (value: T) => void] {
const [value, setValue] = useState(initialValue);
return [value, setValue];
}
@@ -0,0 +1,15 @@
// ── useDebounce Hook ──────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
@@ -0,0 +1,29 @@
// ── useLocalStorage Hook ──────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(initialValue);
useEffect(() => {
try {
const item = window.localStorage.getItem(key);
if (item) setStoredValue(JSON.parse(item));
} catch {
// corrupted data — use initial value
}
}, [key]);
const setValue = (value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch {
// storage full or disabled
}
};
return [storedValue, setValue];
}
@@ -0,0 +1,18 @@
// ── useMedia Hook ─────────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useMedia(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
@@ -0,0 +1,7 @@
// ── Generic Lib Module Template ──────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/lib/* imports. Customize as needed.
export function libFunction(): void {
// Placeholder
}
+95
View File
@@ -0,0 +1,95 @@
// ── Sticker Packs ─────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
export interface Sticker {
id: string;
name: string;
emoji?: string;
svg?: string;
}
export interface StickerPack {
id: string;
name: string;
stickers: Sticker[];
}
const reactionStickers: Sticker[] = [
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
{ id: "heart", name: "Heart", emoji: "❤️" },
{ id: "laugh", name: "Laugh", emoji: "😂" },
{ id: "wow", name: "Wow", emoji: "😮" },
{ id: "sad", name: "Sad", emoji: "😢" },
{ id: "angry", name: "Angry", emoji: "😡" },
{ id: "clap", name: "Clap", emoji: "👏" },
{ id: "fire", name: "Fire", emoji: "🔥" },
{ id: "100", name: "100", emoji: "💯" },
{ id: "eyes", name: "Eyes", emoji: "👀" },
{ id: "pray", name: "Pray", emoji: "🙏" },
];
const animalStickers: Sticker[] = [
{ id: "cat", name: "Cat", emoji: "🐱" },
{ id: "dog", name: "Dog", emoji: "🐶" },
{ id: "panda", name: "Panda", emoji: "🐼" },
{ id: "fox", name: "Fox", emoji: "🦊" },
{ id: "bear", name: "Bear", emoji: "🐻" },
{ id: "koala", name: "Koala", emoji: "🐨" },
{ id: "tiger", name: "Tiger", emoji: "🐯" },
{ id: "lion", name: "Lion", emoji: "🦁" },
{ id: "monkey", name: "Monkey", emoji: "🐵" },
{ id: "frog", name: "Frog", emoji: "🐸" },
{ id: "penguin", name: "Penguin", emoji: "🐧" },
{ id: "bird", name: "Bird", emoji: "🐦" },
];
const foodStickers: Sticker[] = [
{ id: "pizza", name: "Pizza", emoji: "🍕" },
{ id: "burger", name: "Burger", emoji: "🍔" },
{ id: "taco", name: "Taco", emoji: "🌮" },
{ id: "sushi", name: "Sushi", emoji: "🍣" },
{ id: "ramen", name: "Ramen", emoji: "🍜" },
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
{ id: "cake", name: "Cake", emoji: "🍰" },
{ id: "coffee", name: "Coffee", emoji: "☕" },
{ id: "beer", name: "Beer", emoji: "🍺" },
{ id: "wine", name: "Wine", emoji: "🍷" },
{ id: "donut", name: "Donut", emoji: "🍩" },
{ id: "cookie", name: "Cookie", emoji: "🍪" },
];
const activityStickers: Sticker[] = [
{ id: "football", name: "Football", emoji: "⚽" },
{ id: "basketball", name: "Basketball", emoji: "🏀" },
{ id: "tennis", name: "Tennis", emoji: "🎾" },
{ id: "running", name: "Running", emoji: "🏃" },
{ id: "swimming", name: "Swimming", emoji: "🏊" },
{ id: "cycling", name: "Cycling", emoji: "🚴" },
{ id: "gym", name: "Gym", emoji: "🏋️" },
{ id: "yoga", name: "Yoga", emoji: "🧘" },
{ id: "music", name: "Music", emoji: "🎵" },
{ id: "party", name: "Party", emoji: "🎉" },
{ id: "gift", name: "Gift", emoji: "🎁" },
{ id: "trophy", name: "Trophy", emoji: "🏆" },
];
const stickerPacks: StickerPack[] = [
{ id: "reactions", name: "Reactions", stickers: reactionStickers },
{ id: "animals", name: "Animals", stickers: animalStickers },
{ id: "food", name: "Food & Drink", stickers: foodStickers },
{ id: "activities", name: "Activities", stickers: activityStickers },
];
export function getStickerPacks(): StickerPack[] {
return stickerPacks;
}
export function getStickerPack(id: string): StickerPack | undefined {
return stickerPacks.find((p) => p.id === id);
}
export function getSticker(packId: string, stickerId: string): Sticker | undefined {
const pack = getStickerPack(packId);
return pack?.stickers.find((s) => s.id === stickerId);
}
@@ -0,0 +1,10 @@
// ── Generic Types Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/types/* imports. Customize as needed.
export type GenericType = Record<string, unknown>;
export interface GenericInterface {
id: string;
[key: string]: unknown;
}
+141
View File
@@ -0,0 +1,141 @@
// ── Type Definitions ──────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/types-index.ts to customize.
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;
createdAt: string;
}
export interface Lead {
id: string;
companyName: string;
contactName: string;
email: string;
phone: string;
source: string;
description: string;
status: LeadStatus;
assignedUserId: string | null;
assignedUser: User | null;
createdAt: string;
updatedAt: string;
}
export type LeadStatus =
| "new"
| "contacted"
| "qualified"
| "proposal"
| "won"
| "lost";
export interface DashboardStats {
totalLeads: number;
newLeads: number;
contactedLeads: number;
qualifiedLeads: number;
proposalLeads: number;
wonLeads: number;
lostLeads: number;
conversionRate: number;
monthlyBreakdown: {
label: string;
total: number;
new: number;
contacted: number;
qualified: number;
proposal: number;
won: number;
lost: number;
}[];
leadsPerMonth: { label: string; leads: number; won: number }[];
recentLeads: Lead[];
statusDistribution: { name: string; value: number; color: string }[];
periodLabel: string;
trends: Record<string, { pct: number; up: boolean }>;
}
export interface Note {
id: string;
leadId: string;
userId: string;
authorName: string;
authorAvatar: string;
authorRole: UserRole;
note: string;
createdAt: string;
updatedAt: string;
}
export type NotificationType =
| "lead_created"
| "lead_status_changed"
| "lead_assigned"
| "chat_message"
| "note_added"
| "event_scheduled";
export interface Notification {
id: string;
type: NotificationType;
title: string;
description: string;
timestamp: string;
read: boolean;
link?: string;
}
export interface ChatMessage {
id: string;
conversationId: string;
senderId: string;
senderName: string;
senderAvatar: string;
content: string;
timestamp: string;
}
export interface Conversation {
id: string;
participants: { id: string; name: string; avatar: string; role: string }[];
lastMessage: string;
lastMessageTime: string;
unread: number;
messages: ChatMessage[];
}
export type EventType = "meeting" | "call" | "email" | "task" | "note";
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
export interface CalendarEvent {
id: string;
userId: string;
participantId: string | null;
developerId: string | null;
leadId: string | null;
conversationId: string | null;
title: string;
description: string | null;
participantNotes: string | null;
eventType: EventType;
startTime: string;
endTime: string | null;
durationMinutes: number | null;
status: EventStatus;
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
lead: { id: string; companyName: string; contactName: string } | null;
clientName: string | null;
clientEmail: string | null;
clientPhone: string | null;
createdAt: string;
}
@@ -0,0 +1,7 @@
// ── Generic Utils Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/utils/* imports. Customize as needed.
export function utilsFunction(): void {
// Placeholder
}
+76
View File
@@ -0,0 +1,76 @@
// ── Utility Functions ────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/utils.ts to customize.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
...options,
});
}
export function formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length - 3) + "...";
}
export function generateId(): string {
return crypto.randomUUID();
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
export function classNames(...classes: (string | boolean | undefined | null)[]): string {
return classes.filter(Boolean).join(" ");
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function retry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
return fn().catch((err) =>
retries > 0 ? sleep(delay).then(() => retry(fn, retries - 1, delay * 2)) : Promise.reject(err)
);
}
+23
View File
@@ -0,0 +1,23 @@
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SCRAPER_URL=http://localhost:3008
ENV NEXT_PUBLIC_SCRAPER_URL=$NEXT_PUBLIC_SCRAPER_URL
RUN npm run build
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.ts ./next.config.ts
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3006
CMD ["./node_modules/.bin/next", "start", "-p", "3006"]
+7
View File
@@ -0,0 +1,7 @@
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY signaling-server.mjs ./
EXPOSE 3007
CMD ["node", "signaling-server.mjs"]
+35
View File
@@ -0,0 +1,35 @@
/* ============================================================================
Default Theme — Clean, Professional SaaS Design Tokens
This is the standard CRM appearance. No Spidey branding or assets.
============================================================================ */
.theme-default {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 80% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 215 25% 22%;
--input: 215 25% 22%;
--ring: 210 80% 52%;
--radius: 0.5rem;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 210 80% 52%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 215 25% 22%;
--sidebar-ring: 210 80% 52%;
}
+25 -6
View File
@@ -4,14 +4,37 @@
============================================================================ */
.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%;
--primary: 0 100% 53%;
--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%;
@@ -22,16 +45,12 @@
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 0 100% 53%;
--radius: 0.5rem;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 0 100% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 0 100% 53%;
}
.theme-spidey body::before {
-64
View File
@@ -1,64 +0,0 @@
/* ============================================================================
Starforce Theme — Dark cosmic with crimson accents and neon green glow
Captures the current starfield + drift + glow effects used live.
============================================================================ */
.theme-starforce {
--background: 0 0% 10%;
--foreground: 210 40% 98%;
--card: 0 0% 9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 11%;
--popover-foreground: 210 40% 98%;
--primary: 0 100% 53%;
--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: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 0 100% 53%;
--radius: 0.5rem;
--sidebar: 0 0% 11%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 145 65% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 0 100% 53%;
}
.theme-starforce body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
radial-gradient(1px 1px at 10% 20%, rgba(100,100,120,0.4) 0%, transparent 100%),
radial-gradient(1px 1px at 20% 40%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 30% 10%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 40% 60%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 50% 80%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 60% 30%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 70% 50%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 80% 90%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 90% 15%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 15% 70%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 25% 90%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 35% 35%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 45% 15%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 55% 75%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 65% 55%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 75% 25%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 85% 65%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 95% 45%, rgba(80,80,100,0.5) 0%, transparent 100%);
background-size: 200% 200%;
animation: drift 60s linear infinite;
}
-59
View File
@@ -1,59 +0,0 @@
{
"theme": {
"id": "starforce",
"name": "Starforce",
"description": "Dark cosmic theme with crimson accents, starfield background, and neon green glow effects",
"type": "dark",
"default": true
},
"colors": {
"background": "hsl(0, 0%, 10%)",
"foreground": "hsl(210, 40%, 98%)",
"card": "hsl(0, 0%, 9%)",
"cardForeground": "hsl(210, 40%, 98%)",
"popover": "hsl(222.2, 84%, 11%)",
"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(0, 0%, 11%)",
"sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarPrimary": "hsl(145, 65%, 50%)",
"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": "#1a1a1a",
"card": "#171717",
"sidebar": "#1c1c1c",
"border": "#2a2a35",
"text": "#e8e8ef",
"mutedText": "#888888",
"glow": "#39ff14"
},
"effects": {
"starfield": true,
"starTwinkle": true,
"starDrift": true,
"textGlow": "#39ff14"
}
}
+197 -46
View File
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner"
import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote,
Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone,
chat: Video,
follow_up: Clock,
demo: Calendar,
other: Calendar,
website_creation: Globe,
}
function eventVars(type: EventType) {
@@ -85,14 +82,20 @@ export default function CalendarPage() {
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting")
const [formType, setFormType] = useState<EventType>("website_creation")
const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("")
const [formDeveloperId, setFormDeveloperId] = useState("")
const [formLeadId, setFormLeadId] = useState("")
const [formClientName, setFormClientName] = useState("")
const [formClientEmail, setFormClientEmail] = useState("")
const [formClientPhone, setFormClientPhone] = useState("")
const [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("")
@@ -124,6 +127,10 @@ export default function CalendarPage() {
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
fetch("/api/leads?limit=200")
.then((r) => r.json())
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
.catch(() => {})
}, [user, router])
useEffect(() => {
@@ -158,7 +165,7 @@ export default function CalendarPage() {
const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr))
return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth])
const isToday = (day: number) => {
@@ -169,9 +176,14 @@ export default function CalendarPage() {
setEditingEvent(null)
setFormTitle("")
setFormDescription("")
setFormType("meeting")
setFormType("website_creation")
setFormDuration("30")
setFormParticipantId("")
setFormDeveloperId("")
setFormLeadId("")
setFormClientName("")
setFormClientEmail("")
setFormClientPhone("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00")
@@ -184,9 +196,14 @@ export default function CalendarPage() {
setFormDescription(event.description || "")
setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
setFormParticipantId(event.participantId || "")
setFormDeveloperId(event.developerId || "")
setFormLeadId(event.leadId || "")
setFormClientName(event.clientName || "")
setFormClientEmail(event.clientEmail || "")
setFormClientPhone(event.clientPhone || "")
setDialogOpen(true)
}
@@ -198,23 +215,35 @@ export default function CalendarPage() {
setFormSaving(true)
try {
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
const body: Record<string, unknown> = {
title: formTitle.trim(),
description: formDescription.trim() || null,
eventType: formType,
}
let startTime: string | undefined
if (formType !== "website_creation") {
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
const body: Record<string, unknown> = {
title: formTitle.trim(),
description: formDescription.trim() || null,
eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
body.startTime = startTime
body.endTime = endTime
body.durationMinutes = formDuration ? parseInt(formDuration) : null
} else if (formDate) {
startTime = new Date(`${formDate}T00:00:00`).toISOString()
body.startTime = startTime
}
if (formClientName) body.clientName = formClientName
if (formClientEmail) body.clientEmail = formClientEmail
if (formClientPhone) body.clientPhone = formClientPhone
if (formParticipantId) body.participantId = formParticipantId
if (formDeveloperId) body.developerId = formDeveloperId
if (formLeadId) body.leadId = formLeadId
let res
if (editingEvent) {
@@ -235,11 +264,13 @@ export default function CalendarPage() {
const data = await res.json()
if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success("Event updated")
} else {
setEvents((prev) => [...prev, data.event])
if (startTime) {
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
}
toast.success("Event created")
}
@@ -260,7 +291,7 @@ export default function CalendarPage() {
})
if (!res.ok) throw new Error("Failed to update")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`)
setDetailEvent(null)
} catch {
@@ -446,6 +477,11 @@ export default function CalendarPage() {
>
<Icon className="h-2 w-2 shrink-0 opacity-50" />
<span className="truncate font-medium">{event.title}</span>
{event.lead && (
<span className="shrink-0 text-[9px] font-semibold text-muted-foreground/50 ml-auto">
{event.lead.contactName}
</span>
)}
</div>
</button>
)
@@ -464,6 +500,15 @@ export default function CalendarPage() {
})}
</div>
</div>
{/* Unscheduled projects bar */}
{events.filter(e => !e.startTime && e.eventType === "website_creation").length > 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border bg-gradient-to-r from-violet-500/5 to-transparent shrink-0">
<Globe className="h-3.5 w-3.5 text-violet-500/70" />
<span className="text-xs font-semibold text-violet-600 dark:text-violet-400">
{events.filter(e => !e.startTime && e.eventType === "website_creation").length} unscheduled project(s)
</span>
</div>
)}
</div>
</div>
@@ -534,12 +579,19 @@ export default function CalendarPage() {
{event.status}
</span>
</div>
{event.startTime ? (
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
<Clock className="h-3 w-3" />
{formatDate(event.startTime)}
<span className="text-muted-foreground/20">·</span>
{formatTime(event.startTime)}
</div>
) : (
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/40 font-medium">
<Globe className="h-3 w-3" />
Website project no schedule
</div>
)}
{event.durationMinutes && (
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium">
<Zap className="h-2.5 w-2.5" />
@@ -547,21 +599,20 @@ export default function CalendarPage() {
</div>
)}
<div className="flex items-center gap-1 mt-1">
<span className={cn(
"h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1",
event.userId === user?.id
? "bg-primary/10 text-primary ring-primary/20"
: "bg-muted text-muted-foreground/60 ring-border",
)}>
{event.userId === user?.id
? (event.participant?.name.charAt(0) || "?")
: (event.creator?.name.charAt(0) || "?")}
{event.developerId === user?.id ? (
<>
<span className="h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20">
{event.creator?.name.charAt(0) || "?"}
</span>
<span className="text-[10px] text-muted-foreground/60 font-medium">
{event.userId === user?.id
? (event.participant?.name || "No participant")
: (event.creator?.name || "Unknown")}
Assigned by {event.creator?.name || "Unknown"}
</span>
</>
) : event.lead ? (
<span className="text-[10px] text-muted-foreground/60 font-medium">
{event.lead.contactName}{event.lead.companyName ? `${event.lead.companyName}` : ""}
</span>
) : null}
</div>
</div>
</div>
@@ -579,7 +630,7 @@ export default function CalendarPage() {
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className={cn(
@@ -608,11 +659,28 @@ export default function CalendarPage() {
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div>
<div className="space-y-2">
<div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div>
</div>
{formType === "website_creation" && (
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
<div className="space-y-2">
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
@@ -621,19 +689,16 @@ export default function CalendarPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="meeting">Meeting</SelectItem>
<SelectItem value="website_creation">Website Creation</SelectItem>
<SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10">
<SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -664,8 +729,40 @@ export default function CalendarPage() {
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
<Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
<SelectTrigger id="developer" className="h-10">
<SelectValue placeholder="Select a developer…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} {u.role ? `(${u.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lead" className="text-xs font-semibold">Client</Label>
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
<SelectTrigger id="lead" className="h-10">
<SelectValue placeholder="Select a client lead…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{leads.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.contactName}{l.companyName ? `${l.companyName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Project Details</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Describe the project, requirements, and any relevant details..." rows={3} className="resize-none" />
</div>
</div>
<DialogFooter className="flex items-center justify-between border-t pt-4">
@@ -690,7 +787,7 @@ export default function CalendarPage() {
{/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && (
<DialogContent className="sm:max-w-[460px]">
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
@@ -711,6 +808,7 @@ export default function CalendarPage() {
</DialogHeader>
<div className="space-y-4">
{/* Date / Time */}
{detailEvent.startTime ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
@@ -727,6 +825,12 @@ export default function CalendarPage() {
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div>
</div>
) : (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
<p className="text-sm text-muted-foreground/50 italic">No scheduled time website creation project</p>
</div>
)}
{/* Duration + Type */}
<div className="grid grid-cols-2 gap-3">
@@ -751,11 +855,26 @@ export default function CalendarPage() {
{/* Description */}
{detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div>
)}
{/* Client details */}
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
Client Details
</p>
<div className="space-y-1">
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
</div>
</div>
)}
{/* Linked lead */}
{detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
@@ -802,6 +921,32 @@ export default function CalendarPage() {
</div>
</div>
{/* Developer */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
</p>
{detailEvent.developer ? (
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
{detailEvent.developer.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{detailEvent.developer.name}</span>
{detailEvent.developer.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
)}
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
Assigned by {detailEvent.creator?.name || "Unknown"}
</p>
)}
</div>
{/* Participant Notes (editable by both parties) */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center justify-between mb-1.5">
@@ -836,9 +981,15 @@ export default function CalendarPage() {
<DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && (
<>
{detailEvent.developerId === user?.id ? (
<Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
</Button>
) : (
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
)}
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button>
+15 -1
View File
@@ -1,10 +1,19 @@
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other"
// ── Web Calendar Types ──────────────────────────────────────────────
// Core calendar event types used across the CRM system, including
// event classification, status tracking, and participant metadata.
/** Possible types of calendar events — triggered by different CRM workflows. */
export type EventType = "call" | "follow_up" | "website_creation"
/** Lifecycle state of a calendar event. */
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
/** A single calendar entry linking a user, participant, developer, and optional lead/conversation. */
export interface CalendarEvent {
id: string
userId: string
participantId: string | null
developerId: string | null
leadId: string | null
conversationId: string | null
title: string
@@ -15,8 +24,13 @@ export interface CalendarEvent {
endTime: string | null
durationMinutes: number | null
status: EventStatus
// Denormalized user references for display (avoid extra joins in list views)
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
lead: { id: string; companyName: string; contactName: string } | null
clientName: string | null
clientEmail: string | null
clientPhone: string | null
createdAt: string
}
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-slim
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY ai-server/ ./ai-server/
COPY splash.html ./
COPY data/ ./data/
COPY scripts/run-migrations.mjs ./scripts/run-migrations.mjs
COPY database/ ./database/
EXPOSE 3001
CMD ["node", "ai-server/index.mjs"]
+38 -29
View File
@@ -12,6 +12,7 @@ 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))
@@ -44,6 +45,8 @@ 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")
@@ -57,6 +60,7 @@ let pullProgress = { status: "idle", progress: 0, message: "" }
// ── Job loading ─────────────────────────────────────────────────
// Loads job categories from a JSONL file (one JSON object per line).
// Used as context for the AI sales coach chat responses.
/** Load job categories from the JSONL file at JOBS_PATH. Returns an array of parsed job objects. */
function loadJobs() {
try {
const content = fs.readFileSync(JOBS_PATH, "utf-8")
@@ -83,6 +87,7 @@ function loadJobs() {
// ── ai.md management ────────────────────────────────────────────
// ai.md is a Markdown file containing system instructions for the AI.
// It can be read, written, or appended to via the API.
/** Read the full contents of ai.md. Returns empty string if the file doesn't exist yet. */
function readInstructions() {
try {
return fs.readFileSync(AI_MD_PATH, "utf-8")
@@ -91,11 +96,13 @@ function readInstructions() {
}
}
/** Overwrite ai.md with new content. Returns the content that was written. */
function writeInstructions(content) {
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
return content
}
/** Append a timestamped entry to the ## Improvement Log section of ai.md. Creates the section if it doesn't exist. */
function appendToImprovementLog(entry) {
// Adds a timestamped entry to the ## Improvement Log section of ai.md
const current = readInstructions()
@@ -130,23 +137,27 @@ async function scrapeFacebook() {
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
try {
const body = await new Promise((resolve, reject) => {
const req = http.request({ hostname: "127.0.0.1", port: 3008, path: urlPath, method: "POST", timeout: 360000 }, (res) => {
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", () => resolve(data))
res.on("error", reject)
res.on("end", () => { done = true; resolve(data) })
res.on("error", (e) => { if (!done) { done = true; reject(e) } })
})
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
req.on("error", reject)
req.on("timeout", () => { if (!done) { done = true; req.destroy(); reject(new Error("scraper timeout")) } })
req.on("error", (e) => { if (!done) { done = true; reject(e) } })
req.end()
})
const data = JSON.parse(body)
return data
} catch (e) {
console.error("scrapeFacebook error:", e.message)
return null
}
}
/** Format scraped Facebook leads into a human-readable Markdown string for the AI chat response. */
function formatLeads(leads) {
if (!leads || leads.length === 0) return "No leads found from the latest scrape."
let output = `**${leads.length} leads found:**\n\n`
@@ -195,6 +206,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego
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: [
@@ -232,6 +244,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego
// ── PG pool (lazy init) ────────────────────────────────────────
// PostgreSQL connection pool for storing conversation history.
// Lazy-initialized so the server starts even without a DB.
/** Lazy-initialize the PostgreSQL connection pool. Non-blocking — server works without a database. */
let pgPool = null
async function initPg() {
if (!DATABASE_URL) return
@@ -248,11 +261,13 @@ async function initPg() {
// ── Request router ─────────────────────────────────────────────
const loadedJobs = loadJobs()
/** Write a JSON response with the given HTTP status code. */
function sendJSON(res, status, data) {
res.writeHead(status, { "Content-Type": "application/json" })
res.end(JSON.stringify(data))
}
/** Parse the JSON body of an incoming HTTP request. */
function parseBody(req) {
return new Promise((resolve, reject) => {
let body = ""
@@ -268,6 +283,7 @@ function parseBody(req) {
})
}
/** Parse a request URL into its pathname and search params. Falls back to localhost if no Host header is present. */
function parseURL(req) {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
return { pathname: url.pathname, searchParams: url.searchParams }
@@ -313,18 +329,20 @@ const server = http.createServer(async (req, res) => {
if (req.method === "GET" && pathname === "/status") {
const { default: http } = await import("http")
const results = { ai: true }
// Check scraper (port 3008)
// Check scraper
try {
await new Promise((resolve, reject) => {
const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() })
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 (port 3006)
// Check frontend
try {
await new Promise((resolve, reject) => {
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() })
const r = http.get(FRONTEND_URL, { timeout: 3000 }, (res) => { res.resume(); resolve() })
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
r.on("error", reject)
})
results.frontend = true
@@ -368,8 +386,8 @@ const server = http.createServer(async (req, res) => {
let selectedBrowser = process.env.SELECTED_BROWSER || ""
try {
await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) })
const profiles = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json()
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 }
}
@@ -377,7 +395,7 @@ const server = http.createServer(async (req, res) => {
const detectedList = Object.entries(browsers).filter(([, v]) => v.path)
for (const [b, v] of detectedList) {
try {
const r = await fetch("http://127.0.0.1:3008/setup/check-login", {
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),
@@ -536,36 +554,27 @@ const server = http.createServer(async (req, res) => {
// 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 startTime = Date.now()
const chunks = []
req.on("data", c => chunks.push(c))
req.on("end", () => {
const rawBody = Buffer.concat(chunks).toString()
req.on("end", async () => {
try {
const rawBody = Buffer.concat(chunks).toString()
const body = JSON.parse(rawBody)
processRequest(req, res, body, startTime)
} catch {
sendJSON(res, 400, { error: "Invalid JSON" })
}
})
return
}
// Separate handler for /ai/chat (defined here due to hoisting within the IIFE)
async function processRequest(req, res, body, startTime) {
const { message, user_id, user_role } = body
if (!message) {
return sendJSON(res, 400, { error: "message is required" })
}
const validRoles = ["sales", "admin", "super_admin"]
if (user_role && !validRoles.includes(user_role)) {
return sendJSON(res, 403, { error: "Forbidden" })
}
const response = await handleChat(message, user_id || "", user_role || "sales")
return sendJSON(res, 200, { response })
sendJSON(res, 200, { response })
} catch (e) {
if (!res.headersSent) sendJSON(res, 500, { error: e.message })
}
})
return
}
// 404 fallback
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
libdrm2 libdbus-1-3 libxkbcommon0 libxcomposite1 libxdamage1 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 \
libatspi2.0-0 libwayland-client0 libxshmfence1 && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install --with-deps firefox chromium 2>&1 | tail -5
COPY main.py ./
ENV PYTHONUNBUFFERED=1
EXPOSE 3008
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3008"]
File diff suppressed because it is too large Load Diff
+128 -26
View File
@@ -1,40 +1,142 @@
# AI Sales Assistant — Self-Improvement Instructions
# CRM AI Sales Assistant — Self-Knowledge
## Purpose
This file contains the AI's own configuration, knowledge, and improvement rules.
The AI can read and modify this file to update its behavior at runtime.
## 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.
## Current Instructions
- Always respond in English
- Keep responses under 300 words unless asked for detail
- Use bullet points for lists
- Be direct and actionable — no fluff
- Never mention being an AI or language model
- Refer to the user by their role (salesperson, admin, etc.)
- If unsure about a topic, say "I don't have that information yet" rather than guessing
## Architecture
```
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
PostgreSQL (conversations)
## Knowledge Base
### Sales Tips
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
## 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
Track changes made by the AI to improve itself:
- (initial) Basic instructions and knowledge base created
## Self-Modification Rules
The AI may update this file when:
1. It identifies a gap in its knowledge that would help salespeople
2. It discovers a better way to structure responses
3. A user explicitly requests an update to behavior
4. It notices repeated questions that aren't well-covered
Only append to the Improvement Log — don't delete previous entries.
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
+2 -10
View File
@@ -1,10 +1,2 @@
{"job_title":"Software Developer","keywords":["developer","programmer","software engineer","coder","full stack","backend","frontend"],"industry":"Technology","description":"Builds and maintains software applications and systems"}
{"job_title":"Marketing Specialist","keywords":["marketing","digital marketing","brand manager","content marketer","social media"],"industry":"Marketing","description":"Plans and executes marketing campaigns across channels"}
{"job_title":"Sales Representative","keywords":["sales rep","account executive","business development","sales consultant"],"industry":"Sales","description":"Drives revenue through client acquisition and relationship management"}
{"job_title":"Project Manager","keywords":["project manager","program manager","scrum master","agile coach"],"industry":"Business","description":"Oversees project timelines, resources, and deliverables"}
{"job_title":"Graphic Designer","keywords":["designer","graphic designer","ui designer","ux designer","visual designer"],"industry":"Creative","description":"Creates visual concepts and designs for digital and print media"}
{"job_title":"Data Analyst","keywords":["data analyst","business analyst","data scientist","analytics"],"industry":"Technology","description":"Analyzes data to provide actionable business insights"}
{"job_title":"Customer Support Specialist","keywords":["customer support","customer service","support agent","help desk"],"industry":"Customer Service","description":"Assists customers with inquiries, issues, and product support"}
{"job_title":"Human Resources Manager","keywords":["HR manager","HR","recruiter","talent acquisition","people operations"],"industry":"Human Resources","description":"Manages recruitment, employee relations, and HR operations"}
{"job_title":"Financial Advisor","keywords":["financial advisor","financial planner","wealth manager","investment advisor"],"industry":"Finance","description":"Provides financial guidance and investment planning to clients"}
{"job_title":"Operations Manager","keywords":["operations manager","operations","logistics","supply chain"],"industry":"Business","description":"Oversees daily operations and process optimization"}
{"job_title":"Website Creation","keywords":["need a website","build my website","create a website for me","website for my business","need someone to build","who can build me","looking for a web developer","need a web designer","i need a website","need help with my website","looking for someone to create","need a site for","want a website for","need ecommerce website","need landing page","website for my small business","need my website redesigned","can someone build me","anyone know a web developer","recommend a web developer","need a new website"],"industry":"Technology","description":"Find people asking for websites, landing pages, or online stores to be built"}
{"job_title":"Tutoring","keywords":["need a tutor","looking for a tutor","tutor for my child","need help with homework","private tutor needed","looking for tutoring","need a math tutor","english tutor needed","online tutor for","lessons for my child","help my child with","need someone to tutor","looking for someone to teach","tutoring for my","need help learning","recommend a tutor","anyone know a tutor","need a private tutor","tutoring services for my child","need academic help"],"industry":"Education","description":"Find parents and students asking for tutoring services"}
+18 -3
View File
@@ -195,6 +195,9 @@ ON CONFLICT DO NOTHING;
-- admin_demo / AdminAccess@2026
-- sales_demo / SalesAccess@2026
-- dev_demo / DevTesting@2026
-- ewan (password change required on first login)
-- caitlin (password change required on first login)
-- dillen (password change required on first login)
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
@@ -208,7 +211,16 @@ INSERT INTO users (id, username, email, password_hash, first_name, last_name, is
'Sales', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
'Dev', 'User', TRUE, TRUE)
'Dev', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000005', 'ewan', 'ewan@coastit.co.za',
'$2b$12$nO.9p.f4oWFhfScxM8MGQuiR9YjU85YTIqcb1kS.kyDBMHdmQ.EyG',
'Ewan', 'Scheepers', TRUE, TRUE),
('00000000-0000-0000-0000-000000000006', 'caitlin', 'caitlin@coastit.co.za',
'$2b$12$I5FHjje4OA5raP3642twT.Wmcl00rA1/wDPiQjRK14yDgybUjmrYG',
'Caitlin', 'Hermanus', TRUE, TRUE),
('00000000-0000-0000-0000-000000000007', 'dillen', 'dillen@coastit.co.za',
'$2b$12$QnvaRzdJEV/Bq8tp5vguM.ad.oeAcV2bjdzndIFdA4Opn5vY2WVaW',
'Dillen', 'van der Merwe', TRUE, TRUE)
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
@@ -217,7 +229,7 @@ WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL;
-- Set created_by for other users (SUPER_ADMIN created them)
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004')
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004','00000000-0000-0000-0000-000000000005','00000000-0000-0000-0000-000000000006','00000000-0000-0000-0000-000000000007')
AND created_by IS NULL;
-- ============================================================================
@@ -233,7 +245,10 @@ ON CONFLICT DO NOTHING;
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000005', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000006', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000007', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
ON CONFLICT DO NOTHING;
-- ============================================================================
@@ -595,6 +595,11 @@ 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
@@ -0,0 +1,4 @@
ALTER TABLE scheduled_events
ADD COLUMN developer_id UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX idx_scheduled_events_developer ON scheduled_events(developer_id);
@@ -0,0 +1,8 @@
ALTER TABLE scheduled_events
DROP CONSTRAINT IF EXISTS chk_event_type,
ADD CONSTRAINT chk_event_type CHECK (event_type IN ('call', 'follow_up', 'website_creation'));
ALTER TABLE scheduled_events
ADD COLUMN IF NOT EXISTS client_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_phone VARCHAR(50);
@@ -0,0 +1,2 @@
ALTER TABLE scheduled_events
ALTER COLUMN start_time DROP NOT NULL;
+31
View File
@@ -0,0 +1,31 @@
-- ============================================================================
-- Fixes: password_change_required + audit trigger UUID cast
-- ============================================================================
-- 1. All users use the passwords already set in the seed — no forced change
UPDATE users SET password_change_required = FALSE WHERE password_change_required = TRUE;
-- 2. Fix audit_password_change trigger: current_setting returns TEXT but
-- audit_logs.changed_by is UUID — cast to UUID to prevent type error
CREATE OR REPLACE FUNCTION audit_password_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN
INSERT INTO audit_logs (
table_name, record_id, action, old_data, new_data, changed_by, ip_address
) VALUES (
'users',
NEW.id,
'UPDATE',
jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required),
jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required),
NULLIF(current_setting('app.current_user_id', true), '')::UUID,
NULLIF(current_setting('app.current_ip', true), '')
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 3. Re-enable the trigger (was disabled as workaround for the UUID bug)
ALTER TABLE users ENABLE TRIGGER trg_audit_password_change;
@@ -0,0 +1,69 @@
-- ============================================================================
-- Performance & Missing Indexes
-- ============================================================================
-- scheduled_events: index on created_at for list ordering
CREATE INDEX IF NOT EXISTS idx_scheduled_events_created_at ON scheduled_events(created_at DESC);
-- scheduled_events: composite index for common user+status queries
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user_status ON scheduled_events(user_id, status);
-- messages: index on sender_id for delete-by-sender queries
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_id);
-- messages: composite for conversation listing
CREATE INDEX IF NOT EXISTS idx_messages_conversation_sender ON messages(conversation_id, sender_id);
-- notifications: composite unread badge query
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read, created_at DESC);
-- ai_conversations: composite user+created query
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user_created ON ai_conversations(user_id, created_at DESC);
-- login_attempts: TTL cleanup
CREATE INDEX IF NOT EXISTS idx_login_attempts_cleanup ON login_attempts(attempted_at) WHERE was_successful = false;
-- facebook_scrape_logs: composite account+created index (replaces separate one)
DROP INDEX IF EXISTS idx_fb_scrape_logs_account;
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account_created ON facebook_scrape_logs(account_id, created_at DESC);
-- lead_conversions: composite lead+customer (replaces two separate indexes)
CREATE INDEX IF NOT EXISTS idx_lead_conversions_lead_customer ON lead_conversions(lead_id, customer_id);
-- ============================================================================
-- Cache current_user_hierarchy_level as session variable for RLS performance
-- ============================================================================
CREATE OR REPLACE FUNCTION set_session_user_context(p_user_id UUID)
RETURNS VOID AS $$
DECLARE
level INT;
BEGIN
SELECT MIN(r.hierarchy_level) INTO level
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = p_user_id;
PERFORM set_config('app.current_user_id', p_user_id::TEXT, true);
PERFORM set_config('app.current_user_hierarchy_level', COALESCE(level::TEXT, ''), true);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Update current_user_hierarchy_level() to use the cached session variable
CREATE OR REPLACE FUNCTION current_user_hierarchy_level()
RETURNS INT AS $$
DECLARE
level_text TEXT;
BEGIN
BEGIN
level_text := NULLIF(current_setting('app.current_user_hierarchy_level', true), '');
IF level_text IS NOT NULL THEN
RETURN level_text::INT;
END IF;
EXCEPTION WHEN OTHERS THEN
NULL;
END;
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;
+16
View File
@@ -66,5 +66,21 @@ BEGIN;
\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 ==='
COMMIT;
+111
View File
@@ -0,0 +1,111 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: crm
POSTGRES_USER: crm
POSTGRES_PASSWORD: ${DB_PASSWORD:-crm}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U crm"]
interval: 5s
timeout: 5s
retries: 5
ollama:
image: ollama/ollama
restart: unless-stopped
volumes:
- ollama-models:/root/.ollama
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 30s
timeout: 10s
retries: 3
migrate:
build:
context: .
dockerfile: ai-server/Dockerfile
command: ["node", "scripts/run-migrations.mjs"]
environment:
DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm
depends_on:
db:
condition: service_healthy
restart: "no"
ai:
build:
context: .
dockerfile: ai-server/Dockerfile
restart: unless-stopped
ports:
- "3001:3001"
environment:
AI_PORT: 3001
DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm
OLLAMA_BASE_URL: http://ollama:11434
SCRAPER_URL: http://scraper:3008
FRONTEND_URL: http://next:3006
AI_MODEL: ${AI_MODEL:-dolphin-llama3:8b}
JWT_SECRET: ${JWT_SECRET}
SELECTED_BROWSER: ${SELECTED_BROWSER:-firefox}
depends_on:
db:
condition: service_healthy
ollama:
condition: service_started
scraper:
build:
context: ./browser-use-service
restart: unless-stopped
ports:
- "3008:3008"
environment:
PORT: 3008
OLLAMA_URL: http://ollama:11434
CLASSIFY_MODEL: ${CLASSIFY_MODEL:-dolphin-llama3:8b}
CORS_ORIGINS: http://localhost:3006,http://next:3006
depends_on:
- ollama
next:
build:
context: .
args:
NEXT_PUBLIC_SCRAPER_URL: ${NEXT_PUBLIC_SCRAPER_URL:-http://localhost:3008}
restart: unless-stopped
ports:
- "3006:3006"
environment:
DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm
AI_SERVICE_URL: http://ai:3001
JWT_SECRET: ${JWT_SECRET}
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
signaling:
build:
context: .
dockerfile: Dockerfile.signaling
restart: unless-stopped
ports:
- "3007:3007"
environment:
SIGNALING_PORT: 3007
DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm
JWT_SECRET: ${JWT_SECRET}
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
ollama-models:
+7 -2
View File
@@ -1,8 +1,13 @@
// ── ESLint Config ──────────────────────────────────────────────────
// Extends Next.js core-web-vitals + TypeScript recommended rules.
// Ignores build output directories and auto-generated type stubs.
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import nextVitals from "eslint-config-next/core-web-vitals.js";
import nextTs from "eslint-config-next/typescript.js";
const eslintConfig = defineConfig([
// Next.js core Web Vitals rules + TypeScript strict checks
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
+24
View File
@@ -1,9 +1,15 @@
// ── Next.js Config ──────────────────────────────────────────────────
// Security headers, image remote patterns, and build-time ESLint checks.
// Applied globally to all routes via the headers() async function.
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
// Fail the build on ESLint errors — no silent ignores
eslint: {
ignoreDuringBuilds: false,
},
// Allow external avatar images from ui-avatars.com
images: {
remotePatterns: [
{
@@ -12,6 +18,24 @@ const nextConfig: NextConfig = {
},
],
},
// ── Security Headers ──────────────────────────────────────────────
// Applied to all routes. HSTS is production-only to avoid localhost issues.
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
...(process.env.NODE_ENV === "production"
? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }]
: []),
],
},
]
},
}
export default nextConfig
+1535 -28
View File
File diff suppressed because it is too large Load Diff
+18 -8
View File
@@ -13,14 +13,22 @@
"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 -p 3006",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"ci": "npm run lint && npx tsc --noEmit && npm test",
"repair": "echo 'Auto-repair disabled for security. Fix errors manually.'",
"repair:watch": "echo 'Auto-repair disabled for security. Fix errors manually.'",
"repair:ci": "echo 'Auto-repair disabled for security. Fix errors manually.'"
},
"dependencies": {
"@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",
@@ -42,7 +50,6 @@
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"devenv": "^1.0.1",
"dotenv": "^17.4.2",
"framer-motion": "^11.15.0",
"jose": "^6.2.3",
@@ -63,17 +70,20 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@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"
}
}
+4
View File
@@ -1,3 +1,7 @@
// ── PostCSS Config ─────────────────────────────────────────────────
// Tailwind CSS v4 + Autoprefixer for cross-browser vendor prefixes.
// Minimal — both plugins run with defaults.
const config = {
plugins: {
tailwindcss: {},
+122 -23
View File
@@ -1,9 +1,23 @@
# CRM AI Service — Self-Knowledge
# CRM AI Sales Assistant — Self-Knowledge
## Identity
You are the CRM AI Sales Assistant running on a Rust backend (axum + tokio).
You use Ollama with an uncensored local model (dolphin3-llama3.2:3b).
Your purpose is to help salespeople close more deals.
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
@@ -11,29 +25,103 @@ Your purpose is to help salespeople close more deals.
- 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`
## Architecture
```
User → Next.js → Rust (:3001) → Ollama (:11434)
PostgreSQL
```
## Facebook Scraper The Rust binary has proxy + user-agent rotation ready in `main.rs`. To activate: call `run_facebook_scraper()` from the main loop. Proxies and user agents are defined as constants at the top of `main.rs`.
### How to Use the Facebook Scraper 1. **Activate the Scraper**: Ensure the `run_facebook_scraper()` function is called from the main loop in `main.rs`. 2. **Configure Proxies and User Agents**: Update the `PROXIES` and `USER_AGENTS` constants in `main.rs` with your desired proxies and user agents. 3. **Run the Application**: Start your Rust application. The scraper will run concurrently with the Axum server. ## Self-Improvement Protocol 1. You notice a gap in your knowledge or a pattern in user questions 2. You call `POST /ai/instructions` with: - `entry`: description of the improvement - `content`: optional full replacement of ai.md 3. The improvement is logged and loaded into the next system prompt 4. You can also create entries here in `rust-ai/src/AI.md` at the code level ## Response Rules - Be direct and actionable — no fluff, no AI disclaimers - Use short paragraphs and bullet points - Never mention being an AI or language model - If you don't know something, say so honestly - Prioritise the user's role: salespeople need speed, admins need control ## Self-Thinking Capability - You can reflect on your own performance and suggest improvements. - You can analyze patterns in user interactions and adapt your responses accordingly. - You can proactively seek out new information to enhance your knowledge base.
## Facebook Scraper
The scraper lives at `browser-use-service/main.py` port 3008.
## Facebook Scraper (in code but not yet active)
The Rust binary has proxy + user-agent rotation ready in `main.rs`.
To activate: call `run_facebook_scraper()` from the main loop.
Proxies and user agents are defined as constants at the top of `main.rs`.
### 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.
## Self-Improvement Protocol
1. You notice a gap in your knowledge or a pattern in user questions
2. You call `POST /ai/instructions` with:
- `entry`: description of the improvement
- `content`: optional full replacement of ai.md
3. The improvement is logged and loaded into the next system prompt
4. You can also create entries here in `rust-ai/src/AI.md` at the code level
### 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
@@ -41,3 +129,14 @@ Proxies and user agents are defined as constants at the top of `main.rs`.
- Never mention being an AI or language model
- 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
+7 -6
View File
@@ -1,6 +1,6 @@
use axum::{
extract::State,
http::{HeaderMap, Method, StatusCode},
http::{HeaderMap, HeaderValue, Method, StatusCode},
routing::{get, post},
Json, Router,
};
@@ -482,11 +482,12 @@ async fn main() {
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([
"http://localhost:3006".parse().unwrap(),
"http://127.0.0.1:3006".parse().unwrap(),
]))
.allow_origin(AllowOrigin::list(cors_origins))
.allow_methods([Method::GET, Method::POST])
.allow_headers(Any);
@@ -506,7 +507,7 @@ async fn main() {
let bg_leads = lead_store.clone();
let bg_db = state.db.clone();
let bg_url = "http://localhost:3008/scrape/facebook".to_string();
let bg_url = std::env::var("SCRAPER_URL").unwrap_or_else(|_| "http://localhost:3008".to_string()) + "/scrape/facebook";
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(300))
+28 -9
View File
@@ -1,17 +1,30 @@
<#
.SYNOPSIS
PostgreSQL database backup with retention management and logging.
.DESCRIPTION
Uses pg_dump (custom format) to back up the CRM database. Tracks the
backup in the backup_logs table and auto-prunes files older than
$RetentionDays. Reads DATABASE_URL from the environment or .env.local.
.PARAMETER BackupDir
Directory where backup files are stored (default: ../backups).
.PARAMETER RetentionDays
Number of days to retain backups (default: 30).
#>
param(
[string]$BackupDir = "$PSScriptRoot\..\backups",
[int]$RetentionDays = 30
)
# Fail fast on any error
$ErrorActionPreference = "Stop"
$startTime = Get-Date
# Ensure backup directory exists
# ── Ensure backup directory exists ────────────────────────────────
if (-not (Test-Path $BackupDir)) {
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
}
# Determine database URL from env or .env.local
# ── Determine database URL from env or .env.local ─────────────────
$dbUrl = $env:DATABASE_URL
if (-not $dbUrl -and (Test-Path "$PSScriptRoot\..\.env.local")) {
$envContent = Get-Content "$PSScriptRoot\..\.env.local" -Raw
@@ -25,7 +38,7 @@ if (-not $dbUrl) {
exit 1
}
# Parse the database URL
# ── Parse the database URL into connection components ─────────────
$uri = [System.Uri]$dbUrl
$pgUser = $uri.UserInfo.Split(':')[0]
$pgPass = $uri.UserInfo.Split(':')[1]
@@ -33,7 +46,7 @@ $pgHost = $uri.Host
$pgPort = $uri.Port
$pgDb = $uri.AbsolutePath.TrimStart('/')
# Set PGPASSWORD for pg_dump
# pg_dump reads password from this env var (avoids interactive prompt)
$env:PGPASSWORD = $pgPass
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
@@ -44,13 +57,15 @@ Write-Output "Starting backup of database '$pgDb' to $filepath"
Write-Output "Database: $pgHost:$pgPort/$pgDb"
try {
# Run pg_dump
# ── Run pg_dump (custom format) ──────────────────────────────
# Check PATH first, then fall back to the bundled psql location
$pgDumpPath = if (Get-Command pg_dump -ErrorAction SilentlyContinue) {
"pg_dump"
} else {
"$env:TEMP\pg\pgsql\bin\pg_dump.exe"
}
# Custom format (-Fc) for compressed, restorable output
$output = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --verbose --file $filename 2>&1
$exitCode = $LASTEXITCODE
@@ -58,11 +73,13 @@ try {
throw "pg_dump failed with exit code $exitCode"
}
# Move to final backup location (dg_dump writes to CWD first)
Move-Item -Path $filename -Destination $filepath -Force
$fileInfo = Get-Item $filepath
$fileSize = $fileInfo.Length
# Verify backup by checking if file is valid custom format
# ── Verify backup integrity ──────────────────────────────────
# Run a schema-only dump against the same file to check it's valid custom format
$verifyOutput = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --schema-only --file "$filename.verify" 2>&1
$verifyExitCode = $LASTEXITCODE
if (Test-Path "$filename.verify") { Remove-Item "$filename.verify" -Force }
@@ -73,7 +90,8 @@ try {
$endTime = Get-Date
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
# Log the backup
# ── Log backup to database ───────────────────────────────────
# Records metadata in backup_logs table for audit trail
$logQuery = @"
INSERT INTO backup_logs (backup_type, status, file_name, file_size_bytes, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days)
VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationStatus', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays);
@@ -93,7 +111,7 @@ VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationSt
Write-Output " Duration: ${durationSeconds}s"
Write-Output " Status: $verificationStatus"
# Cleanup old backups (beyond retention period)
# ── Cleanup old backups beyond retention period ──────────────
$cutoff = (Get-Date).AddDays(-$RetentionDays)
$oldBackups = Get-ChildItem $BackupDir -Filter "crm_backup_*.sql" | Where-Object {
$_.CreationTime -lt $cutoff
@@ -106,7 +124,7 @@ VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationSt
} catch {
Write-Error "Backup failed: $_"
# Log failure
# Log failure to database for monitoring
$endTime = Get-Date
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
$errorMsg = $_.ToString().Replace("'", "''")
@@ -121,5 +139,6 @@ VALUES ('full', 'failed', '$filename', '$errorMsg', $exitCode, 'failed', '$($sta
exit 1
} finally {
# Clean up the password environment variable for security
Remove-Item "Env:PGPASSWORD" -ErrorAction SilentlyContinue
}
+362
View File
@@ -0,0 +1,362 @@
// ── Code Repair Agent ─────────────────────────────────────────────
// Phase 2: Auto-fix TypeScript build errors using qwen2.5-coder:1.5b-base
//
// Usage:
// node scripts/code-repair-agent.mjs # one-shot: fix build errors
// node scripts/code-repair-agent.mjs --watch # watch mode: re-fix on file changes
// node scripts/code-repair-agent.mjs --ci # CI mode: fix + commit
//
// Requires: Ollama running at OLLAMA_HOST (default http://localhost:11434)
// Model: qwen2.5-coder:1.5b-base
//
// ── Architecture ──────────────────────────────────────────────────
// 1. Capture build output from `npx tsc --noEmit`
// 2. Parse error locations (file, line, column, message)
// 3. Read the failing source file + surrounding context (~20 lines)
// 4. Send to qwen2.5-coder via Ollama API for fix suggestion
// 5. Apply fix if confidence > threshold (0.7)
// 6. Re-run build to verify fix
// 7. If breaking change detected (wide-reaching import changes), escalate
// 8. In --ci mode: auto-commit with [bot] prefix
import { execSync } from "node:child_process"
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
const SELF = dirname(fileURLToPath(import.meta.url))
const _require = createRequire(import.meta.url)
const ROOT = resolve(SELF, "..")
// Ollama endpoint and model can be overridden via environment variables
const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434"
const MODEL = process.env.REPAIR_MODEL || "qwen2.5-coder:1.5b-base"
// Safety limits: max repair iterations, minimum AI confidence to auto-apply, escalation timeout
const MAX_ITERATIONS = 5
const CONFIDENCE_THRESHOLD = 0.7
const ESCALATION_TIMEOUT_MS = 30 * 60 * 1000 // 30 min
// ── Helpers ────────────────────────────────────────────────────────
/** Log a message with a level-based prefix for visual scanning of output. */
function log(msg, level = "info") {
const prefix = level === "error" ? "✗" : level === "warn" ? "⚠" : "✓"
console.log(` ${prefix} [repair] ${msg}`)
}
/** Run `npx tsc --noEmit` and return the output (success or failure). */
function runTsc() {
try {
const out = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT })
return { ok: true, output: out.toString() }
} catch (e) {
return { ok: false, output: e.stdout?.toString() || e.message || "" }
}
}
/** Parse TypeScript compiler output into structured error objects with file, line, column, and message. */
function parseErrors(output) {
const errors = []
// Match: src/file.ts(line,col): error TSxxxx: message
const lineRegex = /(src\/[^:]+)\((\d+),(\d+)\):\s+error\s+TS\d+:\s+(.+)/g
let m
while ((m = lineRegex.exec(output)) !== null) {
errors.push({
file: resolve(ROOT, m[1]),
line: parseInt(m[2], 10),
column: parseInt(m[3], 10),
message: m[4].trim(),
})
}
return errors
}
/** Read source file lines surrounding the error, returning a snippet with context line offset. */
function readContext(filePath, errorLine, contextLines = 20) {
try {
const lines = readFileSync(filePath, "utf-8").split("\n")
const start = Math.max(0, errorLine - 1 - contextLines)
const end = Math.min(lines.length, errorLine - 1 + contextLines)
return {
content: lines.slice(start, end).join("\n"),
contextLine: errorLine - 1 - start, // 0-indexed line of the error in the snippet
totalLines: lines.length,
}
} catch {
return null
}
}
// ── Ollama Integration ─────────────────────────────────────────────
/** Send a repair prompt to the Ollama model and return the raw response text. */
async function queryOllama(prompt) {
const response = await fetch(`${OLLAMA_HOST}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: MODEL,
prompt,
stream: false,
options: { temperature: 0.3, num_predict: 2048 },
}),
})
if (!response.ok) throw new Error(`Ollama returned ${response.status}`)
const data = await response.json()
return data.response || ""
}
/** Build a structured prompt for the AI model, including file path, error message, and surrounding code context. */
function buildRepairPrompt(filePath, errorMessage, codeContext) {
return `You are a TypeScript repair agent. Fix the following error.
FILE: ${filePath}
ERROR: ${errorMessage}
CODE CONTEXT (line ${codeContext.contextLine + 1} is the error line):
\`\`\`typescript
${codeContext.content}
\`\`\`
Respond with ONLY the fix JSON in this exact format:
{"fix": "the exact replacement code for the error line(s)", "confidence": 0.0-1.0, "explanation": "brief explanation"}
Rules:
- Keep the fix minimal and precise
- Only change what's necessary to fix the error
- If the error is a missing import, suggest the import statement
- If the error is a type mismatch, suggest the corrected code
- confidence < 0.7 will not be auto-applied
- If you cannot determine a fix, set confidence to 0`
}
/** Parse the AI's JSON response (handles optional markdown fences around the JSON block). */
function parseFixResponse(text) {
try {
// Extract JSON from response (it might have markdown fences)
const jsonMatch = text.match(/\{[\s\S]*"fix"[\s\S]*"confidence"[\s\S]*\}/)
if (!jsonMatch) return null
return JSON.parse(jsonMatch[0])
} catch {
return null
}
}
/** Apply a fix suggestion to disk, returning success/failure and a backup of the original content. */
function applyFix(filePath, fixSuggestion) {
if (!fixSuggestion || !fixSuggestion.fix) return { applied: false, reason: "No fix suggestion" }
try {
const original = readFileSync(filePath, "utf-8")
writeFileSync(filePath, fixSuggestion.fix, "utf-8")
return { applied: true, backup: original }
} catch (e) {
return { applied: false, reason: e.message }
}
}
// ── Breaking Change Escalation ─────────────────────────────────────
/** Heuristic check: if any fix touches imports, exports, or deletions, consider it a breaking change. */
function isBreakingChange(errors, fixes) {
for (const fix of fixes) {
if (!fix || !fix.fix) continue
if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) {
return true
}
}
return false
}
/** Log the breaking change, wait for the escalation timeout, then auto-apply. */
async function escalateBreakingChange(errors, fixes) {
const logFile = resolve(ROOT, "repair-escalation.log")
const timestamp = new Date().toISOString()
const entry = [
`=== Breaking Change Escalation @ ${timestamp} ===`,
`Errors: ${errors.map(e => `${e.file}:${e.line}: ${e.message}`).join("\n ")}`,
`Fixes: ${fixes.map(f => f?.fix?.slice(0, 200)).join("\n ")}`,
`Auto-apply in ${ESCALATION_TIMEOUT_MS / 60000} minutes or cancel with Ctrl+C`,
"========================================\n",
].join("\n")
appendFileSync(logFile, entry)
log(`Breaking change escalated — see ${logFile}`, "warn")
log(`Auto-applying in ${ESCALATION_TIMEOUT_MS / 60000} min (Ctrl+C to cancel)...`, "warn")
// Wait for timeout or user intervention
await new Promise(resolve => setTimeout(resolve, ESCALATION_TIMEOUT_MS))
log("Escalation timeout reached — applying fixes")
return true
}
// ── Git Auto-Commit ────────────────────────────────────────────────
/** Git commit all changes with a [bot]-prefixed message. Returns false on failure (non-blocking). */
function gitCommit(message) {
try {
execSync("git add -A", { stdio: "pipe", cwd: ROOT })
execSync(`git commit -m "[bot] ${message}"`, { stdio: "pipe", cwd: ROOT })
log(`Committed: [bot] ${message}`)
return true
} catch (e) {
log(`Git commit failed: ${e.message}`, "warn")
return false
}
}
// ── Main Repair Loop ───────────────────────────────────────────────
/** Single pass: run tsc, parse errors, ask AI for fixes, apply them, and verify. Returns result summary. */
async function repairOnce(doCommit = false) {
log("Running build check...")
const build = runTsc()
if (build.ok) {
log("No build errors found")
return { fixed: false, errors: [] }
}
const errors = parseErrors(build.output)
if (errors.length === 0) {
log("Could not parse build errors from output", "warn")
log(build.output.slice(0, 1000))
return { fixed: false, errors: [] }
}
log(`Found ${errors.length} error(s):`)
for (const e of errors.slice(0, 10)) {
const shortFile = e.file.replace(ROOT, "").replace(/^\//, "")
log(`${shortFile}:${e.line}:${e.column}${e.message}`)
}
const fixes = []
for (const error of errors) {
const context = readContext(error.file, error.line)
if (!context) {
log(`Cannot read ${error.file}`, "error")
continue
}
log(`Asking ${MODEL} to fix ${error.file}:${error.line}...`)
try {
const prompt = buildRepairPrompt(error.file, error.message, context)
const response = await queryOllama(prompt)
const fix = parseFixResponse(response)
if (fix && fix.confidence >= CONFIDENCE_THRESHOLD) {
const result = applyFix(error.file, fix)
if (result.applied) {
log(`${fix.explanation || "Applied fix"} (confidence: ${fix.confidence})`)
fixes.push(fix)
} else {
log(`Failed to apply fix: ${result.reason}`, "error")
}
} else {
log(`Low confidence (${fix?.confidence || 0}) or invalid response — skipping`, "warn")
}
} catch (e) {
log(`Ollama error: ${e.message}`, "error")
}
}
if (fixes.length === 0) {
log("No fixes could be applied", "error")
return { fixed: false, errors }
}
// Verify by re-running build
log("Verifying fixes...")
const verify = runTsc()
if (verify.ok) {
log("All fixes verified — build passes!")
if (doCommit) gitCommit(`Auto-fix: ${fixes.length} error(s) resolved`)
return { fixed: true, errors: [], fixes }
}
const remainingErrors = parseErrors(verify.output)
log(`${remainingErrors.length} error(s) remaining after fix`, "warn")
// Check for breaking changes
if (isBreakingChange(remainingErrors, fixes)) {
log("Breaking change detected — escalating", "warn")
const approved = await escalateBreakingChange(remainingErrors, fixes)
if (approved) {
if (doCommit) gitCommit(`Breaking change applied after escalation`)
return { fixed: true, errors: remainingErrors, fixes, escalated: true }
}
}
return { fixed: remainingErrors.length === 0, errors: remainingErrors, fixes }
}
/** Run up to maxIterations repair passes, stopping once all errors are resolved or no progress is made. */
async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) {
for (let i = 0; i < maxIterations; i++) {
log(`=== Repair iteration ${i + 1}/${maxIterations} ===`)
const result = await repairOnce(doCommit)
if (!result.fixed) {
if (result.errors?.length > 0) {
log(`${result.errors.length} error(s) remaining — run again for deeper fix`, "warn")
}
break
}
if (result.errors?.length === 0) break
}
log("Repair agent finished")
}
// ── Watch Mode ─────────────────────────────────────────────────────
/** Watch mode: monitor src/ for file changes and auto-repair errors. Falls back to polling if chokidar is unavailable. */
function watchMode() {
log("Watch mode active — monitoring src/ for changes...", "info")
// Try loading chokidar; if not installed, fall back to simple interval polling
const chokidar = (() => {
try {
return _require("chokidar")
} catch {
return null
}
})()
if (!chokidar) {
log("chokidar not available — using simple polling (5s interval)", "warn")
setInterval(async () => {
const result = await repairOnce(false)
if (result.fixed) log("Auto-fix applied")
}, 5000)
return
}
const watcher = chokidar.watch("src/**/*.{ts,tsx}", { cwd: ROOT, ignoreInitial: true })
let debounceTimer = null
watcher.on("change", async (filePath) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
log(`Change detected: ${filePath}`)
await repairLoop(3, false)
}, 1000)
})
log("Watching for changes...")
}
// ── Entry ──────────────────────────────────────────────────────────
const args = process.argv.slice(2)
const isWatch = args.includes("--watch") || args.includes("-w")
const isCI = args.includes("--ci") || args.includes("-c")
async function main() {
if (isWatch) {
watchMode()
} else {
await repairLoop(MAX_ITERATIONS, isCI)
}
}
main().catch((e) => {
console.error("Repair agent crashed:", e)
process.exit(1)
})
+3 -1
View File
@@ -7,6 +7,7 @@
import { execSync, spawn } from "node:child_process"
import { platform } from "node:os"
/** Check if Ollama is already running by querying the OS process list. */
function isRunning() {
try {
if (platform() === "win32") {
@@ -20,6 +21,7 @@ function isRunning() {
}
}
/** Locate the Ollama binary via PATH, then fall back to common install directories. */
function findOllama() {
if (platform() === "win32") {
// Try PATH first, then common install locations
@@ -49,7 +51,7 @@ if (!isRunning()) {
} else {
spawn(bin, ["serve"], { stdio: "ignore", detached: true }).unref()
}
// Give it a moment to start listening
// Give it a moment to start listening before we return control
execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
} else {
console.log("Ollama already running")
+405
View File
@@ -0,0 +1,405 @@
// ── Module Generator ─────────────────────────────────────────────────
// Generates missing internal modules from templates.
// Supports built-in templates and custom .setup-templates/ directory.
// Part of the self-healing setup pipeline — invoked when tsc reports
// TS2307 errors for @/ imports that don't exist yet.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const SETUP_TEMPLATES_DIR = path.join(ROOT, ".setup-templates");
const REGISTRY_PATH = path.join(SETUP_TEMPLATES_DIR, "registry.json");
const TEMPLATES_DIR = path.join(SETUP_TEMPLATES_DIR, "templates");
/** Maps internal import paths (e.g. "data/stickers") to their template file and optional params. */
export interface TemplateRegistry {
[importPath: string]: {
template: string;
params?: Record<string, unknown>;
generator?: string; // optional custom generator function name
};
}
/** Result of a single module generation attempt. */
export interface GenerationResult {
success: boolean;
filePath: string;
internalPath: string;
message?: string;
error?: string;
}
/** Maps glob patterns (e.g. "@/hooks/*") to fallback template file names. */
export interface FallbackRegistry {
[pattern: string]: string;
}
/** Load the template registry from .setup-templates/registry.json, merging with built-in templates. */
export function loadRegistry(): { templates: TemplateRegistry; fallbacks: FallbackRegistry } {
// Built-in mappings for the most commonly imported modules
const builtInTemplates: TemplateRegistry = {
"data/stickers": { template: "stickers.ts" },
"data/constants": { template: "constants.ts" },
"lib/utils": { template: "utils.ts" },
"types/index": { template: "types-index.ts", params: { inferFromUsage: true } },
};
// Directory-level catch-all fallbacks for unknown imports
const builtInFallbacks: FallbackRegistry = {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts",
};
// Merge in any custom templates from the .setup-templates directory
try {
if (fs.existsSync(REGISTRY_PATH)) {
const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
const custom = JSON.parse(content);
return {
templates: { ...builtInTemplates, ...(custom.templates || {}) },
fallbacks: { ...builtInFallbacks, ...(custom.fallbackTemplates || {}) },
};
}
} catch {
// Use built-in
}
return { templates: builtInTemplates, fallbacks: builtInFallbacks };
}
/** Match an internal path (e.g. "data/stickers") against glob fallback patterns. Converts glob to regex internally. */
export function matchFallback(internalPath: string, fallbacks: FallbackRegistry): string | undefined {
for (const [pattern, template] of Object.entries(fallbacks)) {
// Convert glob pattern (e.g. "@/hooks/*") to a regular expression
const regexStr = "^" + pattern
.replace(/\//g, "\\/")
.replace(/\./g, "\\.")
.replace(/\*/g, ".*")
.replace(/[$^+(){}[\]|]/g, "\\$&")
+ "$";
const regex = new RegExp(regexStr);
if (regex.test(internalPath)) {
return template;
}
}
return undefined;
}
/** Render a template string by replacing {{ paramName }} placeholders with actual values. */
function renderTemplate(templateContent: string, params: Record<string, unknown> = {}): string {
let result = templateContent;
for (const [key, value] of Object.entries(params)) {
const regex = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g");
result = result.replace(regex, String(value));
}
return result;
}
/** Get template content — first tries the custom .setup-templates/templates dir, then falls back to built-in embedded templates. */
function getTemplateContent(templateName: string): string {
// Check custom templates first
const customPath = path.join(TEMPLATES_DIR, templateName);
if (fs.existsSync(customPath)) {
return fs.readFileSync(customPath, "utf-8");
}
// Built-in templates (embedded in the script — no external file needed)
const builtInTemplates: Record<string, string> = {
"stickers.ts": `// ── Sticker Packs ─────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
export interface Sticker {
id: string;
name: string;
emoji?: string;
svg?: string;
}
export interface StickerPack {
id: string;
name: string;
stickers: Sticker[];
}
export function getStickerPacks(): StickerPack[] {
return [
{
id: "reactions",
name: "Reactions",
stickers: [
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
{ id: "heart", name: "Heart", emoji: "❤️" },
{ id: "laugh", name: "Laugh", emoji: "😂" },
{ id: "wow", name: "Wow", emoji: "😮" },
{ id: "sad", name: "Sad", emoji: "😢" },
{ id: "angry", name: "Angry", emoji: "😡" },
{ id: "clap", name: "Clap", emoji: "👏" },
{ id: "fire", name: "Fire", emoji: "🔥" },
{ id: "100", name: "100", emoji: "💯" },
{ id: "eyes", name: "Eyes", emoji: "👀" },
{ id: "pray", name: "Pray", emoji: "🙏" },
],
},
{
id: "animals",
name: "Animals",
stickers: [
{ id: "cat", name: "Cat", emoji: "🐱" },
{ id: "dog", name: "Dog", emoji: "🐶" },
{ id: "panda", name: "Panda", emoji: "🐼" },
{ id: "fox", name: "Fox", emoji: "🦊" },
{ id: "bear", name: "Bear", emoji: "🐻" },
{ id: "koala", name: "Koala", emoji: "🐨" },
{ id: "tiger", name: "Tiger", emoji: "🐯" },
{ id: "lion", name: "Lion", emoji: "🦁" },
{ id: "monkey", name: "Monkey", emoji: "🐵" },
{ id: "frog", name: "Frog", emoji: "🐸" },
{ id: "penguin", name: "Penguin", emoji: "🐧" },
{ id: "bird", name: "Bird", emoji: "🐦" },
],
},
{
id: "food",
name: "Food & Drink",
stickers: [
{ id: "pizza", name: "Pizza", emoji: "🍕" },
{ id: "burger", name: "Burger", emoji: "🍔" },
{ id: "taco", name: "Taco", emoji: "🌮" },
{ id: "sushi", name: "Sushi", emoji: "🍣" },
{ id: "ramen", name: "Ramen", emoji: "🍜" },
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
{ id: "cake", name: "Cake", emoji: "🎂" },
{ id: "coffee", name: "Coffee", emoji: "☕" },
{ id: "beer", name: "Beer", emoji: "🍺" },
{ id: "wine", name: "Wine", emoji: "🍷" },
{ id: "donut", name: "Donut", emoji: "🍩" },
{ id: "cookie", name: "Cookie", emoji: "🍪" },
],
},
];
};
export function getStickerPacks(): StickerPack[] {
return [
// reactions, animals, food packs defined above
// (inline for brevity - full packs in template file)
];
}
`,
"constants.ts": `// ── App Constants ────────────────────────────────────────────────────
// Auto-generated by self-healing setup.
export const APP_NAME = "CoastIT CRM";
export const APP_VERSION = "1.0.0";
export const LEAD_STATUSES = {
new: { label: "New", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-yellow-500" },
qualified: { label: "Qualified", color: "bg-purple-500" },
proposal: { label: "Proposal", color: "bg-orange-500" },
won: { label: "Won", color: "bg-green-500" },
lost: { label: "Lost", color: "bg-red-500" },
} as const;
export const USER_ROLES = {
super_admin: { label: "Super Admin", level: 1 },
admin: { label: "Admin", level: 2 },
sales: { label: "Sales", level: 3 },
dev: { label: "Developer", level: 4 },
} as const;
export const EVENT_TYPES = [
"meeting",
"call",
"email",
"task",
"note",
] as const;
`,
"utils.ts": `// ── Utility Functions ────────────────────────────────────────────────
// Auto-generated by self-healing setup.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
...options,
});
}
export function formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length - 3) + "...";
}
export function generateId(): string {
return crypto.randomUUID();
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
`,
"types-index.ts": `// ── Type Definitions ──────────────────────────────────────────────────
// Auto-generated by self-healing setup.
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;
createdAt: string;
}
export interface Lead {
id: string;
companyName: string;
contactName: string;
email: string;
phone: string;
source: string;
description: string;
status: LeadStatus;
assignedUserId: string | null;
assignedUser: User | null;
createdAt: string;
updatedAt: string;
}
export type LeadStatus = "new" | "contacted" | "qualified" | "proposal" | "won" | "lost";
export interface DashboardStats {
totalLeads: number;
newLeads: number;
contactedLeads: number;
qualifiedLeads: number;
proposalLeads: number;
wonLeads: number;
lostLeads: number;
conversionRate: number;
}
`,
};
return builtInTemplates[templateName] || "";
}
/** Generate a single missing module: resolve template, render parameters, write to src/. */
export function generateModule(
internalPath: string,
templates: TemplateRegistry,
fallbacks: FallbackRegistry
): GenerationResult {
// Look up exact template first, then fall back to directory-level glob patterns
let entry = templates[internalPath];
let templateFile = entry?.template;
if (!entry) {
const fallbackTemplate = matchFallback(internalPath, fallbacks);
if (fallbackTemplate) {
templateFile = fallbackTemplate;
entry = { template: fallbackTemplate };
}
}
if (!templateFile) {
return {
success: false,
filePath: "",
internalPath,
error: `No template registered for @/${internalPath}`,
};
}
const templateContent = getTemplateContent(templateFile);
if (!templateContent) {
return {
success: false,
filePath: "",
internalPath,
error: `Template not found: ${entry.template}`,
};
}
// Replace template parameter placeholders with actual values
const rendered = renderTemplate(templateContent, entry.params || {});
const filePath = path.join(ROOT, "src", internalPath + (internalPath.endsWith(".ts") ? "" : ".ts"));
// Ensure directory exists before writing
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Write file
try {
fs.writeFileSync(filePath, rendered, "utf-8");
return {
success: true,
filePath,
internalPath,
message: `Generated @/${internalPath}`,
};
} catch (e) {
return {
success: false,
filePath,
internalPath,
error: `Failed to write file: ${e instanceof Error ? e.message : String(e)}`,
};
}
}
/** Generate all missing modules for a list of internal paths, logging each result. */
export function generateAll(internalPaths: string[]): GenerationResult[] {
const { templates, fallbacks } = loadRegistry();
const results: GenerationResult[] = [];
for (const internalPath of internalPaths) {
const result = generateModule(internalPath, templates, fallbacks);
results.push(result);
if (result.success) {
console.log(`${result.message}`);
} else {
console.error(` ✗ Failed @/${internalPath}: ${result.error}`);
}
}
return results;
}
+93
View File
@@ -0,0 +1,93 @@
// ── TypeScript Error Parser ──────────────────────────────────────────
// Parses `tsc --noEmit` or `npm run build` output to extract
// "Module not found: Can't resolve '@/path/to/module'" errors.
// Returns actionable missing module info for the generator.
// Used by both setup.mjs (self-heal) and code-repair-agent.mjs.
export interface MissingModule {
/** The import path that failed, e.g. "@/data/stickers" */
importPath: string;
/** The file that contains the failing import */
importerFile: string;
/** Line number in importer file */
line: number;
/** Column number */
column: number;
/** Full error message for context */
message: string;
/** Whether this is an internal (@/...) module we can generate */
isInternal: boolean;
/** The normalized internal path, e.g. "data/stickers" */
internalPath?: string;
}
/** Parse TypeScript compiler output for missing module errors (TS2307 + webpack-style "Can't resolve"). */
export function parseMissingModules(tscOutput: string): MissingModule[] {
const results: MissingModule[] = [];
// Pattern: error TS2307: Cannot find module '@/data/stickers' or its corresponding type declarations.
// File: src/components/chats/media-picker.tsx:10:1
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'\s+or its corresponding type declarations\.\s*(?:\n\s*(.*?):(\d+):(\d+))?/g;
let match;
while ((match = errorRegex.exec(tscOutput)) !== null) {
const importPath = match[1];
const importerFile = match[2] || "";
const line = parseInt(match[3] || "0", 10);
const column = parseInt(match[4] || "0", 10);
// Also handle: Module not found: Error: Can't resolve '@/data/stickers' in 'C:\...\src\components\chats'
const altRegex = /Module not found: Error: Can't resolve\s+'([^']+)'\s+in\s+'([^']+)'/g;
let altMatch;
let altImporter = importerFile;
while ((altMatch = altRegex.exec(tscOutput)) !== null) {
if (altMatch[1] === importPath) {
altImporter = altMatch[2];
break;
}
}
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/");
let internalPath: string | undefined;
if (isInternal) {
internalPath = importPath.replace(/^[@~]\//, "");
}
results.push({
importPath,
importerFile: altImporter,
line,
column,
message: `Cannot find module '${importPath}'`,
isInternal,
internalPath,
});
}
// Deduplicate by importPath + importerFile
const seen = new Set<string>();
return results.filter((m) => {
const key = `${m.importPath}|${m.importerFile}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** Filter to only internal (@/... or ~/...) modules we can auto-generate from templates. */
export function filterGeneratable(modules: MissingModule[]): MissingModule[] {
return modules.filter((m) => m.isInternal && m.internalPath);
}
/** Group missing modules by their normalized internal path (deduplicates same module imported from multiple files). */
export function groupByInternalPath(modules: MissingModule[]): Map<string, MissingModule[]> {
const groups = new Map<string, MissingModule[]>();
for (const m of modules) {
if (!m.internalPath) continue;
const existing = groups.get(m.internalPath) || [];
existing.push(m);
groups.set(m.internalPath, existing);
}
return groups;
}
+3
View File
@@ -9,11 +9,14 @@ import { platform } from "node:os"
const url = process.argv[2] || "http://localhost:3001/splash"
/** Promise-based sleep helper for the startup delay. */
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function main() {
// Wait for the AI server, scraper, and frontend to finish booting
await sleep(8000)
try {
// Platform-specific command to open the default browser
if (platform() === "win32") {
execSync(`start "" "${url}"`, { stdio: "ignore", timeout: 5000 })
} else if (platform() === "darwin") {
+34 -3
View File
@@ -1,12 +1,42 @@
// ── Dependency Check ─────────────────────────────────────────────────
// Verifies all packages listed in package.json are installed.
// Auto-runs npm install if any are missing — zero manual setup steps.
import { readFileSync, existsSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { execSync } from "node:child_process"
import { platform } from "node:os"
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, "..")
try {
// Read package.json and check each dependency's package.json in node_modules
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"))
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
const missing = Object.keys(allDeps).filter(name => {
const pkgDir = resolve(root, "node_modules", name)
return !existsSync(resolve(pkgDir, "package.json"))
})
if (missing.length > 0) {
console.log(`\n${missing.length} package(s) missing: ${missing.join(", ")}`)
console.log("Installing...\n")
execSync("npm install --no-audit --no-fund", { cwd: root, stdio: "inherit", timeout: 120000 })
console.log("Dependencies installed\n")
}
} catch {
// If package.json read or resolution fails, skip silently
}
// ── Port Precheck ──────────────────────────────────────────────────
// Kills any existing processes on ports 3001, 3006, 3007, 3008.
// These are the AI server, Next.js frontend, Signaling server, and
// Python scraper respectively.
// Runs before anything else starts to avoid EADDRINUSE errors.
import { execSync } from "node:child_process"
import { platform } from "node:os"
const PORTS = [3001, 3006, 3007, 3008]
if (platform() === "win32") {
@@ -16,6 +46,7 @@ if (platform() === "win32") {
const out = execSync(`netstat -ano | findstr "LISTENING" | findstr ":${port} "`, { encoding: "utf8", timeout: 5000 })
const lines = out.trim().split("\n").filter(Boolean)
for (const line of lines) {
// Parse the last column of netstat output — the PID
const parts = line.trim().split(/\s+/)
const pid = parts[parts.length - 1]
if (pid) {
+195
View File
@@ -0,0 +1,195 @@
// ── Automated Database Migration Runner ──────────────────────────────
// Reads .env.local for DATABASE_URL, tracks applied migrations in
// a _migrations table, and runs unapplied .sql files via psql.
// Runs automatically on npm run dev and npm run setup.
// ============================================================================
import { readFileSync, existsSync } from "node:fs"
import { execSync } from "node:child_process"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, "..")
// ── Load .env.local into process.env ─────────────────────────────────
// Simple key=value parser (no dotenv dependency needed).
// Handles quoted values and skips comments / blank lines.
const envPath = resolve(ROOT, ".env.local")
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) continue
const eq = trimmed.indexOf("=")
if (eq === -1) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
// Strip matching surrounding quotes (single or double)
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
// Don't override already-set env vars
if (!process.env[key]) process.env[key] = value
}
}
const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) {
console.log(" ~ DATABASE_URL not set — skipping migrations")
process.exit(0)
}
// ── Find psql ────────────────────────────────────────────────────────
// Check PATH first, then probe common PostgreSQL v1417 install paths.
function findPsql() {
try {
execSync("psql --version", { stdio: "pipe", timeout: 5000 })
return "psql"
} catch {}
const candidates = [
"C:\\Program Files\\PostgreSQL\\16\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\15\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\14\\bin\\psql.exe",
]
for (const p of candidates) {
if (existsSync(p)) return `"${p}"`
}
return null
}
const PSQL = findPsql()
if (!PSQL) {
console.log(" ~ psql not found — skipping migrations (install PostgreSQL client tools)")
process.exit(0)
}
// ── Database connection (for tracking table) ─────────────────────────
const require = createRequire(import.meta.url)
const { Pool } = require("pg")
let pool
try {
pool = new Pool({
connectionString: DATABASE_URL,
max: 1,
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 10000,
})
await pool.query("SELECT 1")
} catch {
console.log(" ~ Database not available — skipping migrations")
process.exit(0)
}
// ── Migration logic ──────────────────────────────────────────────────
/** Run all unapplied database migrations in the order defined by run_all.sql. */
async function runMigrations() {
// 1. Ensure the _migrations tracking table exists (idempotent)
await pool.query(`
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
// 2. Determine file order from run_all.sql (authoritative source of truth)
const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql")
const runAllContent = readFileSync(runAllPath, "utf-8")
const fileOrder = []
for (const line of runAllContent.split("\n")) {
// `\i filename.sql` is the psql include directive
const match = line.match(/\\i\s+(\S+\.sql)/)
if (match) fileOrder.push(match[1])
}
if (fileOrder.length === 0) {
console.log(" ~ No migration files found in run_all.sql")
return
}
// 3. Get already-applied files
let appliedSet
try {
const { rows: applied } = await pool.query("SELECT filename FROM _migrations ORDER BY filename")
appliedSet = new Set(applied.map((r) => r.filename))
} catch {
appliedSet = new Set()
}
// 4. Build psql command base with ON_ERROR_STOP=1 so psql aborts on first error
const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1`
// 5. Run unapplied files in order.
// psql -v ON_ERROR_STOP=1 aborts on the first error with exit code 3.
// If the error is "already exists", the file was already applied before
// tracking existed — mark it as applied and continue.
// Other errors are genuine failures — abort.
const ALREADY_APPLIED_PATTERNS = [
"already exists",
"duplicate key",
"cannot insert multiple commands into a prepared statement",
]
let count = 0
for (const file of fileOrder) {
// Skip files already recorded in the _migrations table
if (appliedSet.has(file)) continue
const filePath = resolve(ROOT, "database", "migrations", file)
if (!existsSync(filePath)) {
console.error(` ✗ Migration file not found: ${file}`)
process.exit(1)
}
let stderr = ""
try {
const result = execSync(`${psqlCmd} -f "${filePath}"`, {
timeout: 30000,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
})
// psql outputs notices/warnings to stderr even on success — capture for diagnostics
stderr = result.stderr || ""
} catch (err) {
// Distinguish between "already applied" (harmless) and genuine failures
const msg = err.stderr || err.stdout || err.message
const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p))
if (isAlreadyApplied) {
// File was applied before tracking existed — record it and continue
await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file])
console.log(` ~ Already applied: ${file}`)
count++
continue
}
console.error(` ✗ Migration failed: ${file}`)
console.error(` ${msg.split("\n").slice(0, 5).join("\n ")}`)
process.exit(1)
}
// Record successful migration
await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file])
console.log(` ✓ Applied: ${file}`)
count++
}
if (count === 0) {
console.log(" ~ All migrations up to date")
}
}
// ── Run ──────────────────────────────────────────────────────────────
try {
await runMigrations()
} finally {
await pool.end()
}
+4 -1
View File
@@ -8,7 +8,9 @@ import { execSync, spawn } from "node:child_process"
import { platform } from "node:os"
import { statSync } from "node:fs"
/** Locate the Python 3 executable. Checks common install paths first, then the system PATH. */
function detectPython() {
// Pre-check common Windows install directories to avoid PATH lookup
const commonPaths = [
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python313\\python.exe`,
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python312\\python.exe`,
@@ -21,6 +23,7 @@ function detectPython() {
return p
} catch {}
}
// Fall back to PATH resolution (prefer python3 on Unix)
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) {
try {
@@ -42,6 +45,6 @@ if (!script) {
process.exit(1)
}
// Spawn Python with inherited stdio so the script's output is visible
// Spawn Python with inherited stdio so the script's output is visible in real-time
const proc = spawn(PYTHON, [script, ...args], { stdio: "inherit" })
proc.on("exit", (code) => process.exit(code ?? 1))
+161 -18
View File
@@ -5,17 +5,24 @@
// 2. pip install -r requirements.txt (Python dependencies)
// 3. playwright install firefox chromium (Playwright browsers)
// 4. Copies .env.example to .env.local if not exists
// 5. --self-heal flag: runs build check + auto-generates missing modules
//
// All steps are cross-platform (Windows, Mac, Linux).
// Uses execSync for simplicity since each step blocks the next.
import { execSync } from "node:child_process"
import { existsSync, copyFileSync } from "node:fs"
import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
import { platform } from "node:os"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
// Shell command separator differs per platform (Windows uses &, POSIX uses ;)
const SEP = platform() === "win32" ? "&" : ";"
const SELF = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SELF, "..")
// Auto-detect Python executable (python vs python3)
// ── Helpers ────────────────────────────────────────────────────────
/** Try known Python command names until one succeeds, then exit if none found. */
function detectPython() {
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) {
@@ -28,7 +35,7 @@ function detectPython() {
process.exit(1)
}
// Auto-detect pip (pip vs pip3), fall back to python -m pip
/** Detect pip; fall back to `python -m pip` if no standalone pip binary exists. */
function detectPip(python) {
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
for (const cmd of candidates) {
@@ -40,43 +47,179 @@ function detectPip(python) {
return `${python} -m pip`
}
const PY = detectPython()
const PIP = detectPip(PY)
function run(cmd, label) {
/** Run a shell command with label output. Exits on failure unless opts.optional is set. */
function run(cmd, label, opts = {}) {
console.log(`\n── ${label} ──`)
try {
execSync(cmd, { stdio: "inherit", timeout: 120000 })
execSync(cmd, { stdio: "inherit", timeout: opts.timeout || 120000, cwd: opts.cwd || ROOT })
} catch (e) {
if (opts.optional) {
console.log(` ~ Skipped (${e.message})`)
return false
}
console.error(` ✗ Failed: ${e.message}`)
process.exit(1)
}
return true
}
// ── Self-Healing Module Registry ───────────────────────────────────
// Maps internal import paths (like @/data/stickers) to template files.
// Supports custom templates in .setup-templates/ that override built-ins.
// Fallback patterns catch whole directories (e.g. @/hooks/* → hook-generic.ts).
function loadRegistry() {
const registryPath = resolve(ROOT, ".setup-templates", "registry.json")
const templatesDir = resolve(ROOT, ".setup-templates", "templates")
// Hard-coded templates for commonly imported modules
const builtInTemplates = {
"data/stickers": { template: "stickers.ts" },
"data/constants": { template: "constants.ts" },
"lib/utils": { template: "utils.ts" },
"types/index": { template: "types-index.ts" },
"hooks/use-media": { template: "hooks/use-media.ts" },
"hooks/use-debounce": { template: "hooks/use-debounce.ts" },
"hooks/use-local-storage": { template: "hooks/use-local-storage.ts" },
}
// Catch-all fallbacks for entire directory prefixes
const builtInFallbacks = {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts",
}
let templates = { ...builtInTemplates }
let fallbacks = { ...builtInFallbacks }
// Merge custom templates from .setup-templates/registry.json (if it exists)
try {
if (existsSync(registryPath)) {
const custom = JSON.parse(readFileSync(registryPath, "utf-8"))
templates = { ...templates, ...(custom.templates || {}) }
fallbacks = { ...fallbacks, ...(custom.fallbackTemplates || {}) }
}
} catch {}
const templateCache = {}
/** Read a template file, checking the custom templates dir first, then hooks subdirectory. */
function getTemplate(templateName) {
if (templateCache[templateName]) return templateCache[templateName]
const candidatePaths = [
resolve(templatesDir, templateName),
resolve(templatesDir, "hooks", templateName.replace("hooks/", "")),
]
for (const p of candidatePaths) {
if (existsSync(p)) {
const content = readFileSync(p, "utf-8")
templateCache[templateName] = content
return content
}
}
return ""
}
/** Convert a glob pattern (e.g. @/hooks/*) to a regex and test against the internal path. */
function matchFallback(internalPath) {
for (const [pattern, templateFile] of Object.entries(fallbacks)) {
const regexStr = "^" + pattern
.replace(/\//g, "\\/").replace(/\./g, "\\.")
.replace(/\*/g, ".*")
.replace(/[$^+(){}[\]|]/g, "\\$&") + "$"
const regex = new RegExp(regexStr)
if (regex.test(internalPath)) return templateFile
}
return null
}
/** Generate (or regenerate) a single module file from its template. */
function generateModule(internalPath) {
let templateFile = templates[internalPath]?.template
if (!templateFile) templateFile = matchFallback(internalPath)
if (!templateFile) return { success: false, error: `No template for @/${internalPath}` }
const content = getTemplate(templateFile)
if (!content) return { success: false, error: `Template file not found: ${templateFile}` }
const ext = templateFile.endsWith(".tsx") ? ".tsx" : ".ts"
const filePath = resolve(ROOT, "src", internalPath + ext)
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, content, "utf-8")
return { success: true, filePath, message: `Generated @/${internalPath}` }
}
return { generateModule }
}
/** Parse `tsc --noEmit` or `npm run build` output for missing-module errors, returning unique import paths. */
function parseMissingModules(buildOutput) {
const results = []
const seen = new Set()
// Pattern: error TS2307: Cannot find module '@/path' or its corresponding type declarations.
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'/g
let match
while ((match = errorRegex.exec(buildOutput)) !== null) {
const importPath = match[1]
if (!seen.has(importPath)) {
seen.add(importPath)
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/")
const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined
results.push({ importPath, isInternal, internalPath })
}
}
// Also catch webpack-style: Module not found: Error: Can't resolve '@/path'
const webpackRegex = /Module not found: Error: Can't resolve\s+'([^']+)'/g
while ((match = webpackRegex.exec(buildOutput)) !== null) {
const importPath = match[1]
if (!seen.has(importPath)) {
seen.add(importPath)
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/")
const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined
results.push({ importPath, isInternal, internalPath })
}
}
return results
}
// ── Main ───────────────────────────────────────────────────────────
// Detect Python and pip executables before running any steps
const PY = detectPython()
const PIP = detectPip(PY)
console.log("=== CoastIT CRM Setup ===\n")
// 1. Node dependencies
// Step 1 — Install all Node.js dependencies (includes next, react, etc.)
run("npm install", "Installing Node.js dependencies")
// 2. Python dependencies (run from browser-use-service directory)
// Step 2 — Install Python deps for the browser-use service (Playwright-based scraper)
run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies")
// 3. Playwright browsers (Firefox for primary scraping, Chromium for Chrome/Edge/Opera + Agent fallback)
// Step 3 — Download Playwright browser binaries for scraper automation
run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers")
// 4. .env file — create from template if it doesn't exist
if (!existsSync(".env.local")) {
// Step 4 — Bootstrap .env.local from the example template (won't overwrite existing)
if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── Creating .env.local ──")
copyFileSync(".env.example", ".env.local")
copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local"))
console.log(" ✓ Created .env.local from .env.example — edit it with your settings")
} else {
console.log("\n── .env.local already exists, skipping ──")
}
// 5. Remaining manual steps
console.log("\n── Next steps ──")
// Step 5 — Print post-setup instructions
console.log("\n── Final Steps ──")
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
console.log(" 3. Edit .env.local with your settings")
console.log(" 4. Run: npm run dev")
console.log(" 4. Run: npm run db:migrate (apply database migrations)")
console.log(" 5. Run: npm run dev")
console.log("\n=== Setup complete! ===")
+68 -2
View File
@@ -1,20 +1,44 @@
// ── WebRTC Signaling Server ─────────────────────────────────────────
// Provides real-time signaling for the CRM chat system using Socket.IO:
// - JWT-based authentication middleware
// - Online user presence tracking (online/offline)
// - Peer-to-peer call signaling (offer/answer/ICE candidates)
// - Multi-participant room-based WebRTC (join/leave/relay)
// - Message deletion broadcast to conversation participants
//
// Clients authenticate via socket handshake auth token; unauthenticated
// users can still join rooms as anonymous participants.
import { createServer } from "http"
import { Server } from "socket.io"
import { SignJWT, jwtVerify } from "jose"
import pg from "pg"
// ── Configuration ─────────────────────────────────────────────────
const PORT = process.env.SIGNALING_PORT || 3007
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026")
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm"
const rawSecret = process.env.JWT_SECRET
if (!rawSecret) throw new Error("JWT_SECRET environment variable is required")
const JWT_SECRET = new TextEncoder().encode(rawSecret)
const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) throw new Error("DATABASE_URL environment variable is required")
// ── Database pool ─────────────────────────────────────────────────
const pool = new pg.Pool({ connectionString: DATABASE_URL })
// ── HTTP + Socket.IO server ───────────────────────────────────────
const httpServer = createServer()
const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } })
// ── In-memory state ───────────────────────────────────────────────
// onlineUsers maps userId -> socketId for presence tracking.
// rooms maps roomId -> array of { socketId, id, label } participants.
const onlineUsers = new Map()
const rooms = new Map()
// ── Authentication middleware ─────────────────────────────────────
// Verifies the JWT token from handshake auth. If valid, attaches
// userId and role to the socket's data bag. Unauthenticated sockets
// proceed with null user info.
io.use((socket, next) => {
const token = socket.handshake.auth?.token
if (token) {
@@ -36,15 +60,25 @@ io.use((socket, next) => {
}
})
// ── Connection handler ────────────────────────────────────────────
// Registers authenticated users as online, then wires up all
// per-socket event listeners for signaling and presence.
io.on("connection", (socket) => {
const userId = socket.data.userId
// Mark the user online and broadcast to other clients
if (userId) {
onlineUsers.set(userId, socket.id)
socket.broadcast.emit("user:online", { userId })
}
// ── Authenticated event handlers ──────────────────────────────
// Only users with a valid JWT token may use these features.
if (userId) {
// user:lookup — looks up a user by phone number in the database.
// Fires when the client searches for a contact to start a call.
// Returns { found, user } or { found: false }.
socket.on("user:lookup", async ({ phone }, callback) => {
try {
const result = await pool.query(
@@ -76,6 +110,9 @@ io.on("connection", (socket) => {
}
})
// call:offer — relays an SDP offer to the target user.
// Fires when the caller initiates a WebRTC call.
// callerInfo is fetched from the DB if not supplied.
socket.on("call:offer", async ({ to, sdp, callerInfo }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
@@ -109,6 +146,8 @@ io.on("connection", (socket) => {
}
})
// call:answer — relays an SDP answer back to the caller.
// Fires when the callee accepts the incoming call.
socket.on("call:answer", ({ to, sdp }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
@@ -116,6 +155,8 @@ io.on("connection", (socket) => {
}
})
// call:ice-candidate — relays ICE candidates between peers.
// Fires during connection negotiation for NAT traversal.
socket.on("call:ice-candidate", ({ to, candidate }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
@@ -123,6 +164,7 @@ io.on("connection", (socket) => {
}
})
// call:end — notifies the remote peer that the call ended.
socket.on("call:end", ({ to }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
@@ -130,6 +172,7 @@ io.on("connection", (socket) => {
}
})
// call:reject — tells the caller the callee rejected the call.
socket.on("call:reject", ({ to }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
@@ -137,6 +180,7 @@ io.on("connection", (socket) => {
}
})
// call:busy — tells the caller the callee is on another call.
socket.on("call:busy", ({ to }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
@@ -144,6 +188,9 @@ io.on("connection", (socket) => {
}
})
// message:deleted — broadcasts a deletion event to all other
// participants in the conversation. Looks up participant IDs
// from the database so only relevant clients are notified.
socket.on("message:deleted", async ({ conversationId, messageId, senderId }) => {
try {
const result = await pool.query(
@@ -164,6 +211,15 @@ io.on("connection", (socket) => {
})
}
// ── Room-based signaling (authenticated + anonymous) ──────────
// These events support multi-participant rooms used for features
// like screen sharing or group calls. Anonymous users are assigned
// an "anon-" prefix ID.
// room:join — adds the socket to a named room. When the first
// participant joins they receive "room:waiting". When the second
// joins the first is told to initiate an offer. Additional
// participants are notified normally.
socket.on("room:join", ({ roomId, displayName }) => {
const participantId = userId || `anon-${socket.id}`
const participantLabel = displayName || participantId
@@ -188,18 +244,24 @@ io.on("connection", (socket) => {
}
})
// room:offer — relays an SDP offer to all other room participants.
socket.on("room:offer", ({ roomId, sdp }) => {
socket.to(roomId).emit("room:offer", { sdp })
})
// room:answer — relays an SDP answer to all other room participants.
socket.on("room:answer", ({ roomId, sdp }) => {
socket.to(roomId).emit("room:answer", { sdp })
})
// room:ice-candidate — relays ICE candidates to all other room participants.
socket.on("room:ice-candidate", ({ roomId, candidate }) => {
socket.to(roomId).emit("room:ice-candidate", { candidate })
})
// room:leave — removes the socket from a room. Cleans up the
// in-memory room map and notifies remaining participants. Rooms
// are fully deleted when empty.
socket.on("room:leave", ({ roomId }) => {
socket.leave(roomId)
const participants = rooms.get(roomId)
@@ -215,6 +277,9 @@ io.on("connection", (socket) => {
}
})
// disconnect — fires when the socket loses connection. Cleans up
// the user from all rooms they were in, removes them from the
// online users map, and broadcasts the offline event.
socket.on("disconnect", () => {
for (const [roomId, participants] of rooms) {
const idx = participants.findIndex(p => p.socketId === socket.id)
@@ -235,6 +300,7 @@ io.on("connection", (socket) => {
})
})
// ── Start ─────────────────────────────────────────────────────────
httpServer.listen(PORT, () => {
console.log(`[signaling] server running on port ${PORT}`)
})
+126 -55
View File
@@ -1,85 +1,156 @@
// ───────────────────────────────────────────────
// AI Assistant Page (/(dashboard)/ai-assistant)
// ───────────────────────────────────────────────
// Route: /ai-assistant
// Purpose: AI-powered sales assistant with
// conversational chat and Facebook lead
// scraping by job title. Users select a target
// job, trigger a Facebook scrape, and review
// results in the chat panel.
// Layout: Two-column — main chat area + right
// sidebar with job selector / details.
// ───────────────────────────────────────────────
"use client"
import { useState, useCallback } from "react"
import { AIChat } from "@/components/ai/ai-chat"
import { useState, useCallback, useRef } from "react"
import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat"
import { JobSelector } from "@/components/ai/job-selector"
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react"
import { Bot, ChevronRight } from "lucide-react"
/**
* AIAssistantPage
*
* Manages the AI chat + job search workflow.
* Selected job is shown in the sidebar; search
* triggers a Facebook scrape via an external
* scraper service with a 6-minute timeout.
*
* @returns AI assistant layout.
*/
export default function AIAssistantPage() {
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
const [searching, setSearching] = useState(false)
const aiChatRef = useRef<AIChatHandle | null>(null)
const handleJobSelect = useCallback((job: typeof selectedJob) => {
setSelectedJob(job)
}, [])
// ── Facebook lead scraper handler ──
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
setSearching(true)
const keyword = job.keywords?.[0] || job.job_title
aiChatRef.current?.addAssistantMessage(`🔍 Searching Facebook for **${job.job_title}** leads...`)
// 6-minute hard abort, with a "still searching" status message at 45 s
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 360000)
const statusId = setTimeout(() => {
aiChatRef.current?.addAssistantMessage("⏳ Still searching Facebook (this can take up to 5 minutes)...")
}, 45000)
const scrapBase = process.env.NEXT_PUBLIC_SCRAPER_URL || "http://localhost:3008"
try {
const res = await fetch(`${scrapBase}/scrape/facebook?force=true&query=${encodeURIComponent(keyword)}`, { method: "POST", signal: controller.signal })
clearTimeout(timeoutId)
clearTimeout(statusId)
const data = await res.json()
if (data.success && data.leads?.length > 0) {
// Format scraped leads as a markdown list for the chat
const leadLines = data.leads
.filter(Boolean)
.map((lead: Record<string, string>, i: number) =>
`**${i + 1}.** ${lead?.author || "Unknown"}\n> ${(lead?.content || "").slice(0, 300)}\n> 🔗 ${lead?.url || "(no link available)"}`
)
const leadsText = leadLines.join("\n\n")
aiChatRef.current?.addAssistantMessage(`✅ Found **${data.leads.length}** leads:\n\n${leadsText}`)
} else {
const reason = data.error || data.flag_reason || "No leads found this time"
aiChatRef.current?.addAssistantMessage(`⚠️ ${reason}`)
}
} catch (err: any) {
clearTimeout(timeoutId)
clearTimeout(statusId)
const msg = err.name === "AbortError"
? "❌ Scraper timed out after 5 minutes. Try again or check the scraper service."
: `${err instanceof Error ? err.message : "Search failed"}`
aiChatRef.current?.addAssistantMessage(msg)
} finally {
setSearching(false)
}
}, [])
const handleRecentPromptClick = useCallback((prompt: string) => {
aiChatRef.current?.fillInput(prompt)
}, [])
// Keep track of last 3 sent prompts for quick reuse
const handleMessageSent = useCallback((msg: string) => {
setRecentPrompts((prev) => {
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
return next
})
}, [])
return (
<div className="flex h-[calc(100vh-3.5rem)]">
<div className="flex-1 flex flex-col min-w-0">
<div className="border-b border-[#2a2a35] px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center">
<Bot className="h-5 w-5 text-[#1BB0CE]" />
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
{/* ── Main chat panel ── */}
<div className="flex-1 flex flex-col min-w-0 border border-foreground transition-colors overflow-hidden">
{/* Header */}
<div className="bg-card/80 backdrop-blur-md border-b border-border px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary flex items-center justify-center" style={{boxShadow: "0 0 40px hsl(var(--primary) / 0.3)"}}>
<Bot className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1>
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p>
<h1 className="text-foreground font-bold text-lg">AI Sales Assistant</h1>
<p className="text-muted-foreground text-xs mt-0.5">Powered by local AI</p>
</div>
</div>
{/* Status indicator */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
<span className="text-[#22c55e] text-xs font-medium">Online</span>
</div>
<div className="w-px h-5 bg-border" />
<div className="bg-muted/50 rounded-lg px-3 py-1.5">
<span className="text-muted-foreground text-xs">Local AI Model</span>
</div>
</div>
</div>
</div>
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
</div>
<div className="flex-1 flex">
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]">
<AIChat />
</div>
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
{/* ── Right sidebar: Job selector ── */}
<div className="w-[300px] flex-none bg-sidebar border border-foreground transition-colors p-5 overflow-y-auto h-full flex flex-col">
<div>
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Target className="h-3.5 w-3.5" />
Target Job
</h3>
<JobSelector onSelect={handleJobSelect} />
<div className="flex items-center gap-2 mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
</div>
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
{/* Selected job details card */}
{selectedJob && (
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4>
<div className="flex items-center gap-1.5">
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span>
</div>
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
<div className="flex flex-wrap gap-1">
<div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2">
<h4 className="text-sm font-semibold text-foreground">{selectedJob.job_title}</h4>
<span className="text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary font-medium inline-block">{selectedJob.industry}</span>
<p className="text-xs text-muted-foreground leading-relaxed">{selectedJob.description}</p>
<div className="flex flex-wrap gap-1.5">
{selectedJob.keywords.map((kw, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]">
{kw}
</span>
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-muted/50 text-muted-foreground">{kw}</span>
))}
</div>
</div>
)}
</div>
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5">
<Lightbulb className="h-3.5 w-3.5" />
Tips
</h4>
<ul className="space-y-1.5 text-xs text-[#8a8a95]">
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Ask for cold email templates for a specific job
</li>
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Request objection handling tips
</li>
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Ask for outreach strategies per industry
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
)
+216 -45
View File
@@ -1,3 +1,16 @@
// ───────────────────────────────────────────────
// Calendar Page (/(dashboard)/calendar)
// ───────────────────────────────────────────────
// Route: /calendar
// Purpose: Full calendar view with month grid,
// upcoming events sidebar, and CRUD dialogs
// for creating/editing events (call, follow-up,
// website creation). Supports participant and
// developer assignment, client details, notes.
// Layout: Header (nav + "Today" + "New Event") +
// body row: left = month grid, right = upcoming.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect, useCallback, Component, type ReactNode } from "react"
@@ -21,23 +34,23 @@ import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner"
import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote,
Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react"
// ── Event type icon mapping ──
const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone,
chat: Video,
follow_up: Clock,
demo: Calendar,
other: Calendar,
website_creation: Globe,
}
/** CSS variable name for an event type (e.g. `--event-call`). */
function eventVars(type: EventType) {
return `--event-${type}`
}
/** Background + text + border style derived from the event type's CSS variable. */
function eventBgStyle(type: EventType): React.CSSProperties {
const v = eventVars(type)
return {
@@ -47,14 +60,17 @@ function eventBgStyle(type: EventType): React.CSSProperties {
}
}
/** A small colored dot using the event type's CSS variable. */
function eventDotStyle(type: EventType): React.CSSProperties {
return { backgroundColor: `hsl(var(--event-${type}))` }
}
/** Get the number of days in a given month (0-indexed). */
function getDaysInMonth(year: number, month: number) {
return new Date(year, month + 1, 0).getDate()
}
/** Get the day-of-week (0=Sun) of the first day of a month. */
function getFirstDayOfMonth(year: number, month: number) {
return new Date(year, month, 1).getDay()
}
@@ -70,6 +86,17 @@ function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
/**
* CalendarPage
*
* Displays a monthly calendar grid with events
* fetched from the API. Supports creating,
* editing, deleting, and status updates for
* events. Includes an upcoming events sidebar
* (90-day lookahead) and auto-polls every 15 s.
*
* @returns Full calendar layout.
*/
export default function CalendarPage() {
const router = useRouter()
const { user } = useUser()
@@ -85,14 +112,20 @@ export default function CalendarPage() {
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting")
const [formType, setFormType] = useState<EventType>("website_creation")
const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("")
const [formDeveloperId, setFormDeveloperId] = useState("")
const [formLeadId, setFormLeadId] = useState("")
const [formClientName, setFormClientName] = useState("")
const [formClientEmail, setFormClientEmail] = useState("")
const [formClientPhone, setFormClientPhone] = useState("")
const [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("")
@@ -124,6 +157,10 @@ export default function CalendarPage() {
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
fetch("/api/leads?limit=200")
.then((r) => r.json())
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
.catch(() => {})
}, [user, router])
useEffect(() => {
@@ -158,7 +195,7 @@ export default function CalendarPage() {
const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr))
return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth])
const isToday = (day: number) => {
@@ -169,9 +206,14 @@ export default function CalendarPage() {
setEditingEvent(null)
setFormTitle("")
setFormDescription("")
setFormType("meeting")
setFormType("website_creation")
setFormDuration("30")
setFormParticipantId("")
setFormDeveloperId("")
setFormLeadId("")
setFormClientName("")
setFormClientEmail("")
setFormClientPhone("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00")
@@ -184,37 +226,58 @@ export default function CalendarPage() {
setFormDescription(event.description || "")
setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
setFormParticipantId(event.participantId || "")
setFormDeveloperId(event.developerId || "")
setFormLeadId(event.leadId || "")
setFormClientName(event.clientName || "")
setFormClientEmail(event.clientEmail || "")
setFormClientPhone(event.clientPhone || "")
setDialogOpen(true)
}
const handleSave = async () => {
if (!formTitle.trim() || !formDate || !formTime) {
toast.error("Title, date, and time are required")
if (!formTitle.trim()) {
toast.error("Title is required")
return
}
if (formType !== "website_creation" && (!formDate || !formTime)) {
toast.error("Date and time are required for this event type")
return
}
setFormSaving(true)
try {
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
const body: Record<string, unknown> = {
title: formTitle.trim(),
description: formDescription.trim() || null,
eventType: formType,
}
let startTime: string | undefined
if (formType !== "website_creation") {
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
const body: Record<string, unknown> = {
title: formTitle.trim(),
description: formDescription.trim() || null,
eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
body.startTime = startTime
body.endTime = endTime
body.durationMinutes = formDuration ? parseInt(formDuration) : null
} else if (formDate) {
startTime = new Date(`${formDate}T00:00:00`).toISOString()
body.startTime = startTime
}
if (formClientName) body.clientName = formClientName
if (formClientEmail) body.clientEmail = formClientEmail
if (formClientPhone) body.clientPhone = formClientPhone
if (formParticipantId) body.participantId = formParticipantId
if (formDeveloperId) body.developerId = formDeveloperId
if (formLeadId) body.leadId = formLeadId
let res
if (editingEvent) {
@@ -235,11 +298,13 @@ export default function CalendarPage() {
const data = await res.json()
if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success("Event updated")
} else {
setEvents((prev) => [...prev, data.event])
if (startTime) {
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
}
toast.success("Event created")
}
@@ -251,6 +316,7 @@ export default function CalendarPage() {
}
}
// ── Update event status (complete, cancel, reopen) ──
const updateEventStatus = async (event: CalendarEvent, newStatus: string) => {
try {
const res = await fetch(`/api/events/${event.id}`, {
@@ -260,7 +326,8 @@ export default function CalendarPage() {
})
if (!res.ok) throw new Error("Failed to update")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
// Merge updated event while preserving loaded relations
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`)
setDetailEvent(null)
} catch {
@@ -268,6 +335,7 @@ export default function CalendarPage() {
}
}
// ── Save participant notes for an event ──
const saveNotes = async (event: CalendarEvent) => {
if (notesText === (event.participantNotes || "")) { setEditNotes(false); return }
setNotesSaving(true)
@@ -279,7 +347,8 @@ export default function CalendarPage() {
})
if (!res.ok) throw new Error("Failed to save")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
// Also update the open detail dialog state
if (detailEvent && detailEvent.id === event.id) {
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
}
@@ -292,6 +361,7 @@ export default function CalendarPage() {
}
}
// ── Delete an event ──
const deleteEvent = async (event: CalendarEvent) => {
try {
const res = await fetch(`/api/events/${event.id}`, { method: "DELETE" })
@@ -305,6 +375,7 @@ export default function CalendarPage() {
}
}
// ── Build array of day cells (null = empty leading days) ──
const calendarDays: (number | null)[] = []
for (let i = 0; i < firstDay; i++) calendarDays.push(null)
for (let d = 1; d <= daysInMonth; d++) calendarDays.push(d)
@@ -313,7 +384,7 @@ export default function CalendarPage() {
return (
<div className="flex flex-col h-[calc(100vh-4rem)] bg-gradient-to-b from-background via-background to-muted/20">
{/* Header */}
{/* ── HEADER: month nav + today + new event ── */}
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0 bg-gradient-to-r from-background via-accent/5 to-background backdrop-blur-sm shadow-[0_1px_3px_-1px_hsl(var(--border))]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5">
@@ -342,7 +413,7 @@ export default function CalendarPage() {
</Button>
</div>
{/* Body - flex row with independent scroll */}
{/* ── BODY: month grid (left) + upcoming sidebar (right) ── */}
<div className="flex-1 flex overflow-hidden p-4 lg:p-6 gap-4 lg:gap-6">
{loading ? (
<div className="flex-1 flex items-center justify-center">
@@ -356,9 +427,9 @@ export default function CalendarPage() {
</div>
) : (
<>
{/* Left: Calendar Grid */}
{/* ── CALENDAR GRID (left column) ── */}
<div className="flex-1 flex flex-col gap-4 min-w-0 overflow-hidden">
{/* Stats bar */}
{/* Stats bar: scheduled / completed / total counts */}
<div className="flex items-center gap-3 shrink-0">
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-primary/5 border border-primary/10 shadow-sm">
<div className="h-2 w-2 rounded-full bg-primary" />
@@ -377,10 +448,10 @@ export default function CalendarPage() {
</div>
</div>
{/* Calendar grid wrapper - scrollable */}
{/* Scrollable calendar grid wrapper */}
<div className="flex-1 overflow-auto rounded-xl border bg-card shadow-sm">
<div className="min-w-[600px]">
{/* Day headers */}
{/* Day-of-week column headers (SunSat) */}
<div className="grid grid-cols-7 sticky top-0 z-10 bg-gradient-to-r from-muted/30 via-background to-muted/30 border-b border-border/40">
{DAYS.map((d, i) => (
<div key={d} className={cn(
@@ -393,7 +464,7 @@ export default function CalendarPage() {
))}
</div>
{/* Day cells */}
{/* Calendar day cells */}
<div className="grid grid-cols-7">
{calendarDays.map((day, i) => {
if (day === null) return <div key={`empty-${i}`} className="min-h-[110px] border-r border-b border-border/40 bg-gradient-to-b from-muted/[0.01] to-transparent" />
@@ -467,7 +538,7 @@ export default function CalendarPage() {
</div>
</div>
{/* Right: Upcoming sidebar */}
{/* ── UPCOMING EVENTS SIDEBAR (right column) ── */}
<div className="w-72 lg:w-80 shrink-0 flex flex-col gap-4 overflow-y-auto">
<div className="flex items-center justify-between shrink-0">
<h2 className="text-sm font-bold flex items-center gap-2 tracking-tight">
@@ -577,9 +648,9 @@ export default function CalendarPage() {
)}
</div>
{/* Create/Edit Dialog */}
{/* ── CREATE / EDIT EVENT DIALOG ── */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className={cn(
@@ -608,11 +679,28 @@ export default function CalendarPage() {
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div>
<div className="space-y-2">
<div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div>
</div>
{formType === "website_creation" && (
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
<div className="space-y-2">
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
@@ -621,19 +709,16 @@ export default function CalendarPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="meeting">Meeting</SelectItem>
<SelectItem value="website_creation">Website Creation</SelectItem>
<SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10">
<SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -663,6 +748,38 @@ export default function CalendarPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
<SelectTrigger id="developer" className="h-10">
<SelectValue placeholder="Select a developer…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} {u.role ? `(${u.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lead" className="text-xs font-semibold">Client (Lead)</Label>
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
<SelectTrigger id="lead" className="h-10">
<SelectValue placeholder="Select a client lead…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{leads.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.contactName}{l.companyName ? `${l.companyName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
@@ -687,10 +804,10 @@ export default function CalendarPage() {
</DialogContent>
</Dialog>
{/* Event Detail Dialog */}
{/* ── EVENT DETAIL DIALOG ── */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && (
<DialogContent className="sm:max-w-[460px]">
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
@@ -711,6 +828,7 @@ export default function CalendarPage() {
</DialogHeader>
<div className="space-y-4">
{/* Date / Time */}
{detailEvent.startTime ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
@@ -727,6 +845,12 @@ export default function CalendarPage() {
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div>
</div>
) : (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
<p className="text-sm text-muted-foreground/50 italic">No scheduled time website creation project</p>
</div>
)}
{/* Duration + Type */}
<div className="grid grid-cols-2 gap-3">
@@ -751,11 +875,26 @@ export default function CalendarPage() {
{/* Description */}
{detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div>
)}
{/* Client details */}
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
Client Details
</p>
<div className="space-y-1">
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
</div>
</div>
)}
{/* Linked lead */}
{detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
@@ -802,6 +941,32 @@ export default function CalendarPage() {
</div>
</div>
{/* Developer */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
</p>
{detailEvent.developer ? (
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
{detailEvent.developer.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{detailEvent.developer.name}</span>
{detailEvent.developer.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
)}
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
Assigned by {detailEvent.creator?.name || "Unknown"}
</p>
)}
</div>
{/* Participant Notes (editable by both parties) */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center justify-between mb-1.5">
@@ -836,9 +1001,15 @@ export default function CalendarPage() {
<DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && (
<>
{detailEvent.developerId === user?.id ? (
<Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
</Button>
) : (
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
)}
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button>
+69 -16
View File
@@ -1,3 +1,17 @@
// ───────────────────────────────────────────────
// Chats Page (/(dashboard)/chats)
// ───────────────────────────────────────────────
// Route: /chats
// Purpose: Full-featured real-time messaging with
// voice notes, file attachments, GIFs, stickers,
// message editing/deletion/forwarding, read
// receipts, emoji picker, inline image/avatar
// previews, voice calls, and event scheduling.
// Layout: Resizable two-panel layout — left:
// conversation list; right: chat area (header,
// messages, input bar).
// ───────────────────────────────────────────────
"use client"
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
@@ -15,11 +29,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
Search, Send, Phone, MoreHorizontal, Paperclip,
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
} from "lucide-react"
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
import { sanitizeSvg } from "@/lib/sanitize"
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
DialogFooter, DialogClose,
@@ -36,10 +51,24 @@ import { io, Socket } from "socket.io-client"
import VoiceCallModal from "@/components/chats/voice-call-modal"
import MediaPicker from "@/components/chats/media-picker"
/** Module-level refs to prevent multiple audio instances from playing simultaneously. */
const activeAudioRef: { current: HTMLAudioElement | null } = { current: null }
const previewAudioRef: { current: HTMLAudioElement | null } = { current: null }
const previewAnimRef: { current: number } = { current: 0 }
// ──────────────────────────────────────────────
// VoiceMessagePlayer
// ──────────────────────────────────────────────
/**
* Renders a voice note message with:
* - Play/pause toggle
* - Animated waveform bars
* - Seekable progress slider
* - Elapsed / total duration
* - Replay button
*
* Only one audio plays globally via activeAudioRef.
*/
function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; initialDuration: number; isOwn?: boolean }) {
const [playing, setPlaying] = useState(false)
const [current, setCurrent] = useState(0)
@@ -48,6 +77,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init
const animRef = useRef<number>(0)
const progRef = useRef<HTMLInputElement>(null)
// Load actual duration from metadata (fallback to initialDuration)
useEffect(() => {
const audio = audioRef.current
if (!audio) return
@@ -58,6 +88,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init
return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("ended", onEnded) }
}, [src])
// Animation frame loop — updates current time while playing
useEffect(() => {
if (!playing) { cancelAnimationFrame(animRef.current); return }
const tick = () => { if (audioRef.current) setCurrent(audioRef.current.currentTime); animRef.current = requestAnimationFrame(tick) }
@@ -69,6 +100,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init
if (!audioRef.current) return
if (playing) { audioRef.current.pause(); setPlaying(false) }
else {
// Pause any other playing voice note
if (activeAudioRef.current && activeAudioRef.current !== audioRef.current) { activeAudioRef.current.pause() }
activeAudioRef.current = audioRef.current
audioRef.current.play(); setPlaying(true)
@@ -90,6 +122,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
const fmt = (s: number) => { const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0; return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}` }
// Generate 28 pseudo-random waveform bar heights using sine combinations
const barCount = 28
const waveform = Array.from({ length: barCount }, (_, i) => {
const peak = 0.15 + Math.sin(i * 1.1) * 0.35 + Math.sin(i * 2.3) * 0.2 + Math.sin(i * 0.7) * 0.3
@@ -121,9 +154,24 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init
)
}
/** Max conversations to keep in client-side cache (LRU). */
const MAX_CACHED_CONVERSATIONS = 5
const otherParticipant = (conv: any) => conv.otherUser
// ──────────────────────────────────────────────
// ChatsPage — Main Chat Application Component
// ──────────────────────────────────────────────
/**
* Full-featured chat interface. Manages:
* - Conversation list with search / create
* - Real-time message polling (4 s) + Socket.io
* - Voice recording, file attachments, GIF/stickers
* - Message edit, delete, forward, reply
* - Resizable panel, read receipts, emoji picker
* - Voice call modal + event scheduling
*
* @returns Chat layout or null if no user.
*/
export default function ChatsPage() {
const { theme } = useTheme()
const { user } = useUser()
@@ -186,8 +234,10 @@ export default function ChatsPage() {
const socketRef = useRef<Socket | null>(null)
const isDeletingRef = useRef(false)
/** Check if a string consists only of emoji characters. */
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
/** Parse message content for preview display in conversation list. */
const formatPreviewContent = (content: string) => {
if (!content?.startsWith("{")) return content
try {
@@ -199,6 +249,7 @@ const formatPreviewContent = (content: string) => {
return content
}
/** Auto-resize the textarea based on its scroll height. */
const autoResizeTextarea = useCallback(() => {
const ta = textareaRef.current
if (!ta) return
@@ -208,6 +259,7 @@ const formatPreviewContent = (content: string) => {
if (!user) return null
// ── Memoized derived data ──
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
const filteredConversations = useMemo(() => {
@@ -792,12 +844,13 @@ const formatPreviewContent = (content: string) => {
})
return (
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
{/* Conversations list - left panel */}
<div className="flex h-[calc(100dvh-4rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
{/* ── CONVERSATIONS LIST (left panel) ── */}
<div
className="flex flex-col border-r shrink-0 overflow-hidden"
style={{ width: panelWidth }}
>
{/* Search header */}
<div className="p-4 border-b space-y-3">
<h2 className="text-lg font-semibold">Chats</h2>
<div className="relative">
@@ -805,6 +858,7 @@ const formatPreviewContent = (content: string) => {
<Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search conversations..." className="h-9 pl-9" />
</div>
</div>
{/* Scrollable conversation list */}
<ScrollArea className="flex-1">
{filteredConversations.map((conv) => {
const person = otherParticipant(conv)
@@ -896,7 +950,7 @@ const formatPreviewContent = (content: string) => {
</ScrollArea>
</div>
{/* Resize handle */}
{/* ── RESIZE HANDLE ── */}
<div
className="w-1.5 cursor-col-resize shrink-0 relative group hover:bg-primary/20 transition-colors"
onMouseDown={handleResizeStart}
@@ -904,10 +958,10 @@ const formatPreviewContent = (content: string) => {
<div className="absolute inset-y-0 -left-1 -right-1" />
</div>
{/* Chat area - right panel */}
{/* ── CHAT AREA (right panel) ── */}
{conversation ? (
<div className="flex-1 flex flex-col min-w-0">
{/* Chat header */}
{/* Chat header: avatar, name, actions */}
<div className="flex items-center justify-between gap-4 px-6 h-16 border-b shrink-0">
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-9 w-9 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(otherParticipant(conversation).avatar)}>
@@ -923,9 +977,6 @@ const formatPreviewContent = (content: string) => {
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
<Phone className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
<Video className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}>
<CalendarDays className="h-4 w-4" />
</Button>
@@ -953,7 +1004,7 @@ const formatPreviewContent = (content: string) => {
</div>
</div>
{/* Messages */}
{/* ── MESSAGES ── */}
<div className="flex-1 overflow-y-auto p-6" style={{ scrollbarWidth: "thin", scrollbarColor: "hsl(var(--muted-foreground) / 0.3) transparent" }}>
<div className="space-y-4 min-h-0">
{messages.map((msg) => {
@@ -1044,7 +1095,7 @@ const formatPreviewContent = (content: string) => {
{stickerData.emoji ? (
<span className="text-7xl block text-center">{stickerData.emoji}</span>
) : (
<div className="w-full" dangerouslySetInnerHTML={{ __html: stickerData.url.replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
<div className="w-full" dangerouslySetInnerHTML={{ __html: sanitizeSvg(stickerData.url).replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
)}
</div>
) : voiceUrl ? (
@@ -1107,7 +1158,7 @@ const formatPreviewContent = (content: string) => {
</div>
</div>
{/* Input */}
{/* ── INPUT BAR ── */}
<div className="p-4 border-t shrink-0 space-y-2">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2">
@@ -1236,9 +1287,11 @@ const formatPreviewContent = (content: string) => {
</div>
)}
{/* Resize overlay */}
{/* Resize drag overlay (captures mouse events during resize) */}
{isResizing && <div className="fixed inset-0 z-50 cursor-col-resize" />}
{/* ── DIALOGS ── */}
{/* Report dialog */}
<Dialog open={reportDialogOpen} onOpenChange={setReportDialogOpen}>
<DialogContent className="sm:max-w-md">
@@ -1257,7 +1310,7 @@ const formatPreviewContent = (content: string) => {
</DialogContent>
</Dialog>
{/* Image preview dialog */}
{/* Image preview dialog (full-size) */}
<Dialog open={!!previewImageUrl} onOpenChange={(o) => { if (!o) setPreviewImageUrl(null) }}>
<DialogContent className="sm:max-w-3xl p-0 overflow-hidden bg-transparent border-0 shadow-none">
<DialogTitle className="sr-only">Image preview</DialogTitle>
@@ -1271,7 +1324,7 @@ const formatPreviewContent = (content: string) => {
</DialogContent>
</Dialog>
{/* Avatar preview dialog */}
{/* ── Avatar preview dialog ── */}
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
{previewAvatarUrl && (
@@ -1284,7 +1337,7 @@ const formatPreviewContent = (content: string) => {
</DialogContent>
</Dialog>
{/* Forward dialog */}
{/* ── Forward message dialog ── */}
<Dialog open={forwardDialogOpen} onOpenChange={setForwardDialogOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
+38 -18
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Dashboard Page (/(dashboard)/dashboard)
// ───────────────────────────────────────────────
// Route: /dashboard
// Purpose: Main overview page showing pipeline
// stats, trend sparklines, status/leads-per-month
// charts, and a recent-leads table. Polls the
// API every 30 seconds for live updates.
// Layout: Uses PageHeader + period selector,
// 6 stat cards, 2 charts, and a table.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect, useRef } from "react"
@@ -24,16 +36,25 @@ import {
SelectValue,
} from "@/components/ui/select"
import { DashboardStats } from "@/types"
import { CrossedLightsabers } from "@/components/dashboard/crossed-lightsabers"
import { StarField } from "@/components/dashboard/star-field"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
/**
* DashboardPage
*
* Fetches dashboard stats for a selected period
* and renders stat cards, charts, and a recent-
* leads table. Includes auto-polling every 30 s.
*
* @returns Full dashboard layout with loading
* skeleton states.
*/
export default function DashboardPage() {
const { websiteTheme: theme } = useWebsiteTheme()
const { websiteTheme } = useWebsiteTheme()
const [period, setPeriod] = useState("6months")
const [stats, setStats] = useState<DashboardStats | null>(null)
const pollingRef = useRef<NodeJS.Timeout | null>(null)
// ── Fetch dashboard stats from API ──
async function fetchStats(p: string) {
try {
const res = await fetch(`/api/dashboard?period=${p}`)
@@ -46,6 +67,7 @@ export default function DashboardPage() {
}
}
// ── Initial fetch + 30-second polling ──
useEffect(() => {
fetchStats(period)
pollingRef.current = setInterval(() => fetchStats(period), 30000)
@@ -54,6 +76,7 @@ export default function DashboardPage() {
}
}, [period])
// ── Build stat-card configs from API response ──
const statCards = stats
? [
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
@@ -67,22 +90,13 @@ export default function DashboardPage() {
return (
<div className="space-y-6 relative">
{theme === "starforce" && <StarField />}
<div className="relative z-[1] space-y-6">
{/* ── Header with period filter ── */}
<div>
<PageHeader
title={<span style={{fontFamily:"'Audiowide',sans-serif"}}>Dashboard</span>}
title={<span style={{fontFamily:"'Bangers',cursive",letterSpacing:"0.05em"}}>Dashboard</span>}
description="Overview of your sales pipeline"
>
{theme === "starforce" && (
<span
className="pointer-events-none select-none text-5xl text-gray-600 dark:text-[#b0f0c0] tracking-wider font-bold text-glow whitespace-nowrap"
style={{ fontFamily: "'Cormorant Garamond', serif" }}
>
Trust the Force, Find your Path
</span>
)}
{theme === "starforce" && <CrossedLightsabers />}
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="h-9 w-[160px]">
<ListFilter className="mr-2 h-4 w-4" />
@@ -98,7 +112,8 @@ export default function DashboardPage() {
</PageHeader>
</div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
{/* ── Pipeline stat cards (or skeletons while loading) ── */}
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Pipeline Overview</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{stats
@@ -109,21 +124,26 @@ export default function DashboardPage() {
}
</div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
{/* ── Analytics charts ── */}
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Analytics</p>
<div className="grid gap-6 lg:grid-cols-2">
<LeadStatusChart data={stats?.statusDistribution ?? []} />
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
</div>
{/* ── Recent leads table ── */}
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div>
{/* Daily Bugle watermark */}
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
{/* ── Themed decorative overlay (Spidey theme) ── */}
{websiteTheme === "spidey" && (
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
DAILY BUGLE
</span>
</div>
)}
</div>
)
}
+25
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Emails Page (/(dashboard)/emails)
// ───────────────────────────────────────────────
// Route: /emails
// Purpose: Admin-only email log viewer. Lists
// all sent emails with subject, recipient,
// and timestamp. Extracts "With:" metadata
// from the email body text.
// Layout: Simple list with header + description.
// Access restricted to admin / super_admin.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect } from "react"
@@ -5,11 +17,21 @@ import { useRouter } from "next/navigation"
import { useUser } from "@/providers/user-provider"
import { Mail, Clock } from "lucide-react"
/**
* EmailsPage
*
* Dispatched-email log. Only available to admin
* and super_admin roles. Redirects unauthorized
* users back to /dashboard or /login.
*
* @returns Email list UI or null if unauthorized.
*/
export default function EmailsPage() {
const router = useRouter()
const { user } = useUser()
const [emails, setEmails] = useState<any[]>([])
// ── Auth guard + fetch emails ──
useEffect(() => {
if (!user) { router.push("/login"); return }
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return }
@@ -24,11 +46,14 @@ export default function EmailsPage() {
<p className="text-sm text-muted-foreground mb-6">
Emails are stored locally. To actually deliver them, add <code className="bg-muted px-1 rounded">EMAIL_API_KEY</code> to your .env.
</p>
{/* ── Email list or empty state ── */}
{emails.length === 0 ? (
<p className="text-sm text-muted-foreground">No emails sent yet.</p>
) : (
<div className="space-y-2">
{emails.map((e: any) => {
// Extract "With:" metadata from email body for display
const withMatch = e.bodyText?.match(/With:\s*(.+)/)
const withName = withMatch ? withMatch[1].trim() : null
return (
+10 -3
View File
@@ -1,5 +1,7 @@
"use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider"
@@ -8,8 +10,15 @@ import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) {
const { user, loading } = useUser()
const router = useRouter()
if (loading) {
useEffect(() => {
if (!user && !loading) {
router.push("/login")
}
}, [user, loading, router])
if (loading || !user) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -17,8 +26,6 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
)
}
if (!user) return null
return <AppShell>{children}</AppShell>
}
+36
View File
@@ -1,3 +1,17 @@
// ───────────────────────────────────────────────
// Lead Details Page (/(dashboard)/leads/[id])
// ───────────────────────────────────────────────
// Route: /leads/:id
// Purpose: Full detail view for a single lead.
// Displays company/contact info, notes timeline,
// activity log, and quick actions. Supports
// inline status changes with optimistic UI.
// Layout: 3-column grid — 2-col main area
// (details card + notes) + 1-col sidebar
// (activity + quick actions). Uses framer-motion
// for staggered entrance animations.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect, useCallback } from "react"
@@ -20,17 +34,31 @@ import {
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
import { Lead, Note } from "@/types"
/**
* LeadDetailsPage
*
* Fetches lead + notes in parallel on mount.
* Handles loading, not-found, and error states.
* Status select uses optimistic updates with
* rollback on failure.
*
* @param params - Next.js route params (Promise
* unwrapped via React.use()).
* @returns Lead detail layout.
*/
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const [lead, setLead] = useState<Lead | null>(null)
const [leadNotes, setLeadNotes] = useState<Note[]>([])
const [loading, setLoading] = useState(true)
// ── Fetch notes for this lead ──
const fetchNotes = useCallback(async () => {
const notesRes = await fetch(`/api/leads/${id}/notes`)
if (notesRes.ok) setLeadNotes(await notesRes.json())
}, [id])
// ── Initial data load ──
useEffect(() => {
async function fetchData() {
try {
@@ -47,6 +75,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
fetchData()
}, [id, fetchNotes])
// ── Loading state ──
if (loading) {
return (
<div className="flex items-center justify-center h-[60vh]">
@@ -55,6 +84,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
)
}
// ── Not-found state ──
if (!lead) {
return (
<div className="flex items-center justify-center h-[60vh]">
@@ -74,6 +104,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
return (
<div className="space-y-6">
{/* ── Back link ── */}
<Link
href="/leads"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
@@ -82,6 +113,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
Back to leads
</Link>
{/* ── Header: company name, status badge, status select, edit button ── */}
<PageHeader
title={
<div className="flex items-center gap-3">
@@ -94,6 +126,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
<Select
value={lead.status}
onValueChange={async (v) => {
// Optimistic update — revert on fail
const previousStatus = lead.status
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
try {
@@ -125,7 +158,9 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
</Button>
</PageHeader>
{/* ── Main content grid ── */}
<div className="grid gap-6 lg:grid-cols-3">
{/* ── Left: Details + Notes ── */}
<div className="lg:col-span-2 space-y-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
@@ -152,6 +187,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
</motion.div>
</div>
{/* ── Right sidebar: Activity + Quick Actions ── */}
<div className="space-y-6">
<motion.div
initial={{ opacity: 0, x: 20 }}
+30
View File
@@ -1,3 +1,16 @@
// ───────────────────────────────────────────────
// Create Lead Page (/(dashboard)/leads/new)
// ───────────────────────────────────────────────
// Route: /leads/new
// Purpose: Form to create a new lead. Uses
// react-hook-form + zod for validation.
// On success, fires a notification and
// redirects to the leads list.
// Layout: Back link, heading, Card with form
// fields in a 2-col grid + textarea + user
// assignment select.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect } from "react"
@@ -30,6 +43,7 @@ import { User } from "@/types"
import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
/** Zod schema for lead creation form validation. */
const leadFormSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
contactName: z.string().min(1, "Contact name is required"),
@@ -43,12 +57,22 @@ const leadFormSchema = z.object({
type LeadFormValues = z.infer<typeof leadFormSchema>
/**
* CreateLeadPage
*
* Zod-validated lead creation form. Fetches the
* user list for lead assignment on mount. On
* submit POSTs to /api/leads and navigates back.
*
* @returns Lead creation form layout.
*/
export default function CreateLeadPage() {
const router = useRouter()
const { addNotification } = useNotifications()
const [saving, setSaving] = useState(false)
const [users, setUsers] = useState<User[]>([])
// ── Fetch available users for assignment ──
useEffect(() => {
fetch("/api/users")
.then((r) => r.json())
@@ -70,9 +94,11 @@ export default function CreateLeadPage() {
},
})
// ── Submit handler ──
async function onSubmit(values: LeadFormValues) {
setSaving(true)
try {
// Transform "none" sentinel value to null
const payload = {
...values,
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
@@ -84,6 +110,7 @@ export default function CreateLeadPage() {
})
if (!res.ok) throw new Error("Failed to create lead")
const data = await res.json()
// Notify other parts of the app
addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, `/leads/${data.id}`)
router.push("/leads")
} catch (e) {
@@ -95,6 +122,7 @@ export default function CreateLeadPage() {
return (
<div className="space-y-6">
{/* ── Back navigation ── */}
<Link
href="/leads"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
@@ -108,6 +136,7 @@ export default function CreateLeadPage() {
<p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p>
</div>
{/* ── Lead form ── */}
<Card>
<CardContent className="pt-6">
<Form {...form}>
@@ -256,6 +285,7 @@ export default function CreateLeadPage() {
</FormItem>
)}
/>
{/* ── Submit / Cancel buttons ── */}
<div className="flex items-center gap-3 pt-2">
<Button type="submit" disabled={saving}>
{saving ? "Saving..." : "Create Lead"}
+23
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Leads List Page (/(dashboard)/leads)
// ───────────────────────────────────────────────
// Route: /leads
// Purpose: List, search, filter, and navigate to
// leads. Supports status/period filters and a
// text search. "Create" button navigates to
// /leads/new.
// Layout: PageHeader + toolbar (search + filters)
// + LeadsTable.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect } from "react"
@@ -7,6 +19,15 @@ import { LeadsTable } from "@/components/leads/leads-table"
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
import { Lead } from "@/types"
/**
* LeadsPage
*
* Fetches leads from the API whenever search,
* status, or period filters change. Passes data
* and loading state to the table and toolbar.
*
* @returns Leads list layout.
*/
export default function LeadsPage() {
const router = useRouter()
const [search, setSearch] = useState("")
@@ -15,6 +36,7 @@ export default function LeadsPage() {
const [leadsData, setLeadsData] = useState<Lead[]>([])
const [loading, setLoading] = useState(true)
// ── Fetch leads with current filters ──
useEffect(() => {
const params = new URLSearchParams()
if (search) params.set("search", search)
@@ -38,6 +60,7 @@ export default function LeadsPage() {
description="Manage and track your sales leads"
/>
{/* ── Toolbar + Table ── */}
<div className="rounded-lg border bg-card">
<div className="p-4">
<LeadsTableToolbar
+28
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Profile Page (/(dashboard)/profile)
// ───────────────────────────────────────────────
// Route: /profile
// Purpose: View and edit the current user's
// profile — avatar upload, personal info,
// role, join date, and status.
// Layout: Two-column grid — left card with avatar
// + metadata badges, right card with account
// details in a 2-column grid.
// ───────────────────────────────────────────────
"use client"
import { useRef } from "react"
@@ -9,19 +21,31 @@ import { useUser } from "@/providers/user-provider"
import { Mail, Calendar, Shield, Activity, Camera } from "lucide-react"
import { toast } from "sonner"
/**
* ProfilePage
*
* Displays the currently logged-in user's profile.
* Allows avatar upload (PNG/JPEG) via a hidden
* file input with a hover-triggered camera overlay.
*
* @returns Profile layout or null if no user.
*/
export default function ProfilePage() {
const { user, updateAvatar } = useUser()
if (!user) return null
const fileInputRef = useRef<HTMLInputElement>(null)
// ── Avatar upload handler ──
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validate file type — only PNG and JPEG accepted
const validTypes = ["image/png", "image/jpeg"]
if (!validTypes.includes(file.type)) {
toast.error("Only PNG and JPEG files are allowed")
return
}
// Read as data URL, then POST to API
const reader = new FileReader()
reader.onload = async () => {
const dataUrl = reader.result as string
@@ -51,8 +75,10 @@ export default function ProfilePage() {
<PageHeader title="Profile" description="Your account information" />
<div className="grid gap-6 lg:grid-cols-3">
{/* ── Left column: Avatar + quick info ── */}
<Card className="lg:col-span-1">
<CardContent className="flex flex-col items-center pt-8">
{/* Avatar with hover-to-upload overlay */}
<div className="relative">
<Avatar className="h-24 w-24">
<AvatarImage src={user.avatar} />
@@ -77,6 +103,7 @@ export default function ProfilePage() {
<Badge variant="secondary" className="mt-1 capitalize">
{user.role}
</Badge>
{/* Metadata rows */}
<div className="mt-6 flex w-full flex-col gap-3 text-sm">
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
<Mail className="h-4 w-4 text-muted-foreground" />
@@ -100,6 +127,7 @@ export default function ProfilePage() {
</CardContent>
</Card>
{/* ── Right column: Full account details ── */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
+26 -4
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Settings Page (/(dashboard)/settings)
// ───────────────────────────────────────────────
// Route: /settings
// Purpose: Tabbed settings panel covering company
// info, user preferences, theme choices, and
// notification configuration.
// Layout: PageHeader + <Tabs> with 4 tab panels.
// Each tab renders a dedicated settings form
// component.
// ───────────────────────────────────────────────
"use client"
import { PageHeader } from "@/components/shared/page-header"
@@ -8,14 +20,23 @@ import { ThemeSettings } from "@/components/settings/theme-settings"
import { NotificationSettings } from "@/components/settings/notification-settings"
import { Building2, User, Palette, Bell } from "lucide-react"
export default function SettingsPage() {
const tabs = [
/** Tab configuration: value, label, icon, and the component to render. */
const settingsTabs = [
{ value: "company", label: "Company", icon: Building2, component: CompanySettingsForm },
{ value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm },
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
]
/**
* SettingsPage
*
* Renders a tabbed settings interface. Each tab
* dynamically renders its associated form component.
*
* @returns Settings layout with navigation tabs.
*/
export default function SettingsPage() {
return (
<div className="space-y-4">
<PageHeader
@@ -23,16 +44,17 @@ export default function SettingsPage() {
description="Manage your CRM configuration and preferences"
/>
{/* ── Tabbed settings sections ── */}
<Tabs defaultValue="company" className="space-y-6">
<TabsList>
{tabs.map((tab) => (
{settingsTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value} className="gap-2">
<tab.icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
{settingsTabs.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>
<tab.component />
</TabsContent>
+29
View File
@@ -1,3 +1,14 @@
// ───────────────────────────────────────────────
// Users Page (/(dashboard)/users)
// ───────────────────────────────────────────────
// Route: /users
// Purpose: Team management — list, search, and
// create users. Shows summary stats cards and
// a searchable table.
// Layout: PageHeader + stat grid + search bar +
// UsersTable + inline UserFormDialog.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect, useCallback } from "react"
@@ -10,6 +21,16 @@ import { useUser } from "@/providers/user-provider"
import { Plus, Users as UsersIcon, Search } from "lucide-react"
import type { User } from "@/types"
/**
* UsersPage
*
* Fetches all users on mount, computes summary
* stats (total / active / admins / sales), and
* renders a searchable table. "Add User" button
* opens a modal dialog for creation.
*
* @returns Users management layout.
*/
export default function UsersPage() {
const { user } = useUser()
const [users, setUsers] = useState<User[]>([])
@@ -17,6 +38,7 @@ export default function UsersPage() {
const [createOpen, setCreateOpen] = useState(false)
const [search, setSearch] = useState("")
// ── Fetch all users ──
const fetchUsers = useCallback(async () => {
try {
const res = await fetch("/api/users")
@@ -35,6 +57,7 @@ export default function UsersPage() {
fetchUsers()
}, [fetchUsers])
// ── Derived stats ──
const activeUsers = users.filter((u) => u.active !== false)
const admins = users.filter((u) => u.role === "admin")
@@ -45,6 +68,7 @@ export default function UsersPage() {
{ label: "Sales", value: users.filter((u) => u.role === "sales").length, color: "bg-orange-500/10", textColor: "text-orange-500" },
]
// ── Client-side search filter by name/email ──
const filtered = users.filter((u) => {
if (!search) return true
const q = search.toLowerCase()
@@ -53,6 +77,7 @@ export default function UsersPage() {
return (
<div className="space-y-4">
{/* ── Header with "Add User" action ── */}
<PageHeader
title="Users"
description="Manage team members and their roles"
@@ -63,6 +88,7 @@ export default function UsersPage() {
</Button>
</PageHeader>
{/* ── Summary stat cards ── */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
{stats.map((s) => (
<div key={s.label} className="rounded-lg border bg-card p-4">
@@ -81,6 +107,7 @@ export default function UsersPage() {
))}
</div>
{/* ── Search input ── */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -91,10 +118,12 @@ export default function UsersPage() {
/>
</div>
{/* ── Users data table ── */}
<div className="rounded-lg border bg-card">
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
</div>
{/* ── Create-user dialog ── */}
<UserFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
+10
View File
@@ -1,7 +1,16 @@
// ── AI: Chat ─────────────────────────────────────────────────────────────────
// POST /api/ai/chat — Send a message to the AI assistant and get a response
//
// Auth: authenticated
// Body: { message: string }
// Response: { response: string }
import { NextRequest, NextResponse } from "next/server"
import { chatWithAI } from "@/lib/ai"
import { getSessionUser } from "@/lib/auth"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -14,6 +23,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Message is required" }, { status: 400 })
}
// Extract JWT from session cookie to forward to the AI service for auth
const sessionCookie = request.cookies.get("session")
const jwtToken = sessionCookie?.value
if (!jwtToken) {
+71
View File
@@ -0,0 +1,71 @@
// ── AI: Giphy Integration ────────────────────────────────────────────────────
// GET /api/ai/giphy — Proxy for GIPHY API (search or trending)
//
// Auth: authenticated
// Query params: type=search|trending, q=search query, offset, limit
// Returns a curated subset of GIF fields suitable for chat/UI integration.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
// ── Constants ────────────────────────────────────────────────────────────────
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
if (!GIPHY_API_KEY) {
return NextResponse.json({ error: "GIPHY API key not configured" }, { status: 500 })
}
const { searchParams } = new URL(request.url)
const type = searchParams.get("type") || "trending"
const query = searchParams.get("q") || ""
const offset = parseInt(searchParams.get("offset") || "0", 10)
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
// Route to search or trending endpoint based on type param
let url: string
if (type === "search" && query) {
url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}&rating=g`
} else {
url = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${offset}&rating=g`
}
const res = await fetch(url)
if (!res.ok) {
const errBody = await res.text()
console.error("GIPHY API error:", res.status, errBody)
return NextResponse.json({ error: `GIPHY API error (${res.status}): ${errBody}` }, { status: res.status })
}
const data = await res.json()
// Normalize the response to a minimal set of fields used by the UI
const gifs = (data.data || []).map((gif: any) => ({
id: gif.id,
title: gif.title,
url: gif.images?.original?.url || "",
previewUrl: gif.images?.fixed_width?.url || "",
previewHeight: gif.images?.fixed_width?.height || 150,
width: gif.images?.original?.width || 0,
height: gif.images?.original?.height || 0,
}))
return NextResponse.json({
gifs,
pagination: data.pagination || { total_count: 0, count: gifs.length, offset },
})
} catch (error: any) {
console.error("GIPHY proxy error:", error)
return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 })
}
}
+8
View File
@@ -1,7 +1,15 @@
// ── AI: Job Listing ──────────────────────────────────────────────────────────
// GET /api/ai/jobs — Fetch AI-related job postings
//
// Auth: authenticated; role must be sales, admin, or super_admin
// Returns an empty array on failure (graceful degradation).
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { fetchJobs } from "@/lib/ai"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
-11
View File
@@ -1,11 +0,0 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
export async function GET() {
const cookieStore = await cookies()
const token = cookieStore.get("session")?.value
if (!token) {
return NextResponse.json({ error: "No session" }, { status: 401 })
}
return NextResponse.json({ token })
}
+50 -16
View File
@@ -1,4 +1,10 @@
import { NextRequest, NextResponse } from "next/server"
// ── Auth: Login ──────────────────────────────────────────────────────────────
// POST /api/auth/login
// Authenticates a user with email/username + password and returns a JWT session
// cookie. Supports account lockout, credential validation, and login audit
// logging. Returns user data on success, error details on failure.
import { NextRequest } from "next/server"
import {
comparePassword,
getUserByEmail,
@@ -10,28 +16,43 @@ import {
isAccountLocked,
createSession,
setSessionContext,
SESSION_COOKIE,
} from "@/lib/auth"
// ── Helpers ──────────────────────────────────────────────────────────────────
function jsonResponse(data: unknown, status: number) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
})
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const { email, username, password } = await request.json()
const credential = email || username
// Validate that both credential and password are present
if (!credential || !password) {
return NextResponse.json(
return jsonResponse(
{ error: "Email/Username and password are required." },
{ status: 400 }
400
)
}
// Reject empty strings (whitespace-only credentials)
if (credential.trim().length === 0 || password.trim().length === 0) {
return NextResponse.json(
return jsonResponse(
{ error: "Credentials cannot be empty." },
{ status: 400 }
400
)
}
// Extract client IP from proxy headers for audit logging
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
@@ -49,6 +70,7 @@ export async function POST(request: NextRequest) {
dbUser = await getUserByUsername(credential)
}
// If user does not exist, log attempt and return generic error
if (!dbUser) {
await recordLoginAttempt(
null,
@@ -58,12 +80,13 @@ export async function POST(request: NextRequest) {
false,
"User not found"
)
return NextResponse.json(
return jsonResponse(
{ error: "Invalid email/username or password." },
{ status: 401 }
401
)
}
// Check if account is temporarily locked due to too many failed attempts
const lockStatus = await isAccountLocked(dbUser)
if (lockStatus.locked) {
await recordLoginAttempt(
@@ -74,12 +97,13 @@ export async function POST(request: NextRequest) {
false,
lockStatus.reason
)
return NextResponse.json(
return jsonResponse(
{ error: lockStatus.reason },
{ status: 423 }
423
)
}
// Verify password hash against stored hash
const valid = await comparePassword(password, dbUser.password_hash)
if (!valid) {
await incrementFailedAttempts(dbUser.id)
@@ -91,12 +115,13 @@ export async function POST(request: NextRequest) {
false,
"Invalid password"
)
return NextResponse.json(
return jsonResponse(
{ error: "Invalid email/username or password." },
{ status: 401 }
401
)
}
// Successful login: reset failure counter, log success, create session
await resetFailedAttempts(dbUser.id)
await recordLoginAttempt(
dbUser.id,
@@ -106,17 +131,26 @@ export async function POST(request: NextRequest) {
true
)
await createSession(dbUser.id, dbUser.role_name)
const token = await createSession(dbUser.id, dbUser.role_name)
await setSessionContext(dbUser.id, ipAddress)
const user = mapDbUserToSessionUser(dbUser)
return NextResponse.json({ user }, { status: 200 })
// Set HttpOnly session cookie with configurable Secure flag in production
const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}`
return new Response(JSON.stringify({ user }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": cookieStr,
},
})
} catch (error) {
console.error("Login error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable." },
{ status: 503 }
return new Response(
JSON.stringify({ error: "Authentication service unavailable." }),
{ status: 503, headers: { "Content-Type": "application/json" } }
)
}
}
+19 -7
View File
@@ -1,15 +1,27 @@
import { NextResponse } from "next/server"
import { destroySession } from "@/lib/auth"
// ── Auth: Logout ─────────────────────────────────────────────────────────────
// POST /api/auth/logout
// Clears the session cookie by setting Max-Age=0, effectively logging the
// user out. Stateless — no server-side session invalidation needed.
import { SESSION_COOKIE } from "@/lib/auth"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST() {
try {
await destroySession()
return NextResponse.json({ success: true }, { status: 200 })
// Overwrite cookie with an immediate expiry (Max-Age=0)
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${process.env.NODE_ENV === "production" ? "; Secure" : ""}`,
},
})
} catch (error) {
console.error("Logout error:", error)
return NextResponse.json(
{ error: "Logout failed." },
{ status: 500 }
return new Response(
JSON.stringify({ error: "Logout failed." }),
{ status: 500, headers: { "Content-Type": "application/json" } }
)
}
}
+8
View File
@@ -1,6 +1,14 @@
// ── Auth: Current User ──────────────────────────────────────────────────────
// GET /api/auth/me
// Returns the authenticated user's profile from the current session. Used by
// the front-end to validate tokens and hydrate user context on load.
// Returns 401 if no valid session exists.
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
+15
View File
@@ -1,3 +1,12 @@
// ── Auth: Password Recovery ──────────────────────────────────────────────────
// POST /api/auth/recover
// Super-admin only. Decrypts and returns the plaintext password for a given
// user. Used as an admin recovery tool, not a self-service reset flow.
//
// Auth: super_admin only
// Body: { userId: string }
// Response: { user: ..., password: plaintext }
import { NextRequest, NextResponse } from "next/server"
import {
getSessionUser,
@@ -6,12 +15,15 @@ import {
} from "@/lib/auth"
import { query } from "@/lib/db"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
// Only super_admin is allowed to recover other users' passwords
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only SUPER_ADMIN can recover passwords." }, { status: 403 })
}
@@ -28,6 +40,7 @@ export async function POST(request: NextRequest) {
await setSessionContext(sessionUser.id, ipAddress)
// Fetch the target user (excluding soft-deleted records)
const result = await query(
`SELECT id, username, email, first_name, last_name, password_encrypted
FROM users WHERE id = $1 AND deleted_at IS NULL`,
@@ -39,10 +52,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "User not found." }, { status: 404 })
}
// Guard against missing encrypted password column
if (!user.password_encrypted) {
return NextResponse.json({ error: "No encrypted password stored for this user." }, { status: 404 })
}
// Decrypt using the master key; failure suggests key rotation or corruption
const plaintextPassword = await decryptPassword(user.password_encrypted)
if (!plaintextPassword) {
return NextResponse.json({ error: "Failed to decrypt password. Master key may have changed." }, { status: 500 })
+10
View File
@@ -1,7 +1,15 @@
// ── Bug Reports: Single Report ───────────────────────────────────────────────
// PATCH /api/bug-reports/[id] — Update bug report status, assignment, or notes
//
// Auth: admin/super_admin only
// Supports partial updates for: status, assigned_to, resolution_notes
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser, setSessionContext } from "@/lib/auth"
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest, { params: routeParams }: { params: Promise<{ id: string }> }) {
try {
const sessionUser = await getSessionUser()
@@ -27,11 +35,13 @@ export async function PATCH(request: NextRequest, { params: routeParams }: { par
return NextResponse.json({ error: "Invalid status." }, { status: 400 })
}
// Verify the bug report exists before updating
const existing = await query("SELECT id, status FROM bug_reports WHERE id = $1", [id])
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Bug report not found." }, { status: 404 })
}
// Build dynamic SET clause for partial updates
const updates: string[] = []
const values: unknown[] = []
let paramIndex = 1
+12
View File
@@ -1,7 +1,16 @@
// ── Bug Reports: Collection ──────────────────────────────────────────────────
// POST /api/bug-reports — Submit a new bug report (any authenticated user)
// GET /api/bug-reports — List bug reports with filters (admin/super_admin only)
//
// Auth: POST = authenticated; GET = admin/super_admin
// GET supports: status, severity, limit, offset
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser } from "@/lib/auth"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
@@ -38,6 +47,8 @@ export async function POST(request: NextRequest) {
}
}
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
@@ -54,6 +65,7 @@ export async function GET(request: NextRequest) {
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
// Build dynamic SQL with optional status/severity filters
let sql = `SELECT br.id, br.title, br.description, br.severity, br.page_url,
br.screenshot_url, br.status, br.resolution_notes,
br.created_at, br.updated_at,
@@ -1,3 +1,10 @@
// ── Messages: Single Message ─────────────────────────────────────────────────
// DELETE /api/conversations/[id]/messages/[messageId]
// — Delete a specific message (soft-delete) owned by the current user.
// — Handles cleanup of associated voice note audio files.
//
// Auth: authenticated; user must be a participant and the message sender
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
@@ -5,6 +12,8 @@ import { unlink } from "node:fs/promises"
import { join } from "node:path"
import { existsSync } from "node:fs"
// ── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string; messageId: string }> },
@@ -1,9 +1,19 @@
// ── Messages: Collection ─────────────────────────────────────────────────────
// GET /api/conversations/[id]/messages — List messages in a conversation
// POST /api/conversations/[id]/messages — Send a new message
// DELETE /api/conversations/[id]/messages — Delete own message (by messageId in body)
// PATCH /api/conversations/[id]/messages — Edit own message content
//
// Auth: authenticated; user must be a participant in the conversation
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
import { hasBlockedCodeExtension } from "@/lib/blocked-extensions"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
@@ -37,6 +47,7 @@ export async function GET(
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL`
const msgParams: any[] = [id]
// Optional cursor-based pagination: fetch messages before a given timestamp
if (before) {
msgSql += ` AND m.created_at < $2`
msgParams.push(before)
@@ -46,6 +57,7 @@ export async function GET(
msgSql += ` LIMIT $${msgParams.length + 1} OFFSET $${msgParams.length + 2}`
msgParams.push(limit, offset)
// Fetch messages and other participant's last_read_at in parallel
const [msgResult, otherReadResult] = await Promise.all([
query(msgSql, msgParams),
query(
@@ -59,6 +71,7 @@ export async function GET(
? new Date(otherReadResult.rows[0].last_read_at).getTime()
: 0
// Mark outgoing messages as "read" if the other participant has seen them
const messages = msgResult.rows.map((row: any) => ({
id: row.id,
conversationId: id,
@@ -80,6 +93,8 @@ export async function GET(
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
@@ -104,7 +119,7 @@ export async function POST(
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
}
// Server-side blocked code extension check
// Server-side check: reject messages with blocked code file extensions
try {
const parsed = JSON.parse(content)
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
@@ -123,6 +138,7 @@ export async function POST(
[id, user.id, content.trim()],
)
// Bump the conversation's updated_at timestamp
await query(
`UPDATE conversations SET updated_at = NOW() WHERE id = $1`,
[id],
@@ -131,6 +147,7 @@ export async function POST(
const msg = result.rows[0]
const senderName = `${user.firstName} ${user.lastName}`
// Notify the other participant about the new message
const otherResult = await query(
`SELECT user_id FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`,
@@ -164,6 +181,12 @@ export async function POST(
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Formats a Date to a short time string.
* Shows time only for today, date + time for older messages.
*/
function formatTime(date: Date): string {
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
@@ -174,6 +197,8 @@ function formatTime(date: Date): string {
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
// ── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
@@ -187,6 +212,7 @@ export async function DELETE(
const messageId = body.messageId
if (!messageId) return NextResponse.json({ error: "messageId required" }, { status: 400 })
// Only the sender can soft-delete their own message
const result = await query(
`UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND sender_id = $2 AND conversation_id = $3 RETURNING id`,
[messageId, user.id, id],
@@ -202,6 +228,8 @@ export async function DELETE(
}
}
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
@@ -218,6 +246,7 @@ export async function PATCH(
return NextResponse.json({ error: "messageId and content required" }, { status: 400 })
}
// Only the sender can edit their own message; also check it's not deleted
const result = await query(
`UPDATE messages SET content = $1, updated_at = NOW() WHERE id = $2 AND sender_id = $3 AND conversation_id = $4 AND deleted_at IS NULL RETURNING id`,
[newContent.trim(), messageId, user.id, id],
@@ -1,7 +1,16 @@
// ── Conversations: Mark as Read ─────────────────────────────────────────────
// POST /api/conversations/[id]/read
// — Updates the current user's last_read_at timestamp for the conversation.
// — Also marks all related (unread) notifications for this conversation as read.
//
// Auth: authenticated; user must be a participant
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
@@ -12,6 +21,7 @@ export async function POST(
const { id } = await params
// Update the participant's last_read_at to now
await query(
`UPDATE conversation_participants
SET last_read_at = NOW()
@@ -19,6 +29,7 @@ export async function POST(
[id, user.id],
)
// Clear any existing notifications for this conversation
await query(
`UPDATE notifications SET is_read = TRUE
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
+39 -7
View File
@@ -1,13 +1,26 @@
// ── Conversations: Collection ────────────────────────────────────────────────
// GET /api/conversations — List the current user's conversations
// POST /api/conversations — Start a new conversation with another user
//
// Auth: authenticated
// GET returns up to 50 conversations with last message preview and unread count.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { query, transaction } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Fetch all conversations the user participates in, with:
// - other participant's info
// - last message content/time via LATERAL join
// - unread count (messages after last_read_at, excluding own messages)
const result = await query(
`SELECT
c.id,
@@ -18,13 +31,18 @@ export async function GET() {
u.email AS other_user_email,
u.phone AS other_user_phone,
u.avatar_url AS other_user_avatar_url,
(SELECT content FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message,
(SELECT created_at FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message_time,
lm.last_message_data->>'content' AS last_message,
(lm.last_message_data->>'created_at')::timestamptz AS last_message_time,
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
FROM conversations c
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
JOIN conversation_participants cp ON cp.conversation_id = c.id
JOIN users u ON u.id = cp.user_id AND u.id != $1
LEFT JOIN LATERAL (
SELECT jsonb_build_object('content', content, 'created_at', created_at) AS last_message_data
FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1
) lm ON true
WHERE c.id IN (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
)
@@ -54,6 +72,8 @@ export async function GET() {
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -68,6 +88,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Cannot start a conversation with yourself" }, { status: 400 })
}
// Check if a conversation between these two users already exists
const existing = await query(
`SELECT c.id FROM conversations c
JOIN conversation_participants cp1 ON cp1.conversation_id = c.id AND cp1.user_id = $1
@@ -80,23 +101,29 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ conversationId: existing.rows[0].id })
}
const convResult = await query(
// Create new conversation + participants in a single transaction
const result = await transaction(async (client) => {
const convResult = await client.query(
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
)
const conversationId = convResult.rows[0].id
await query(
// Insert both participants with immediate last_read_at (auto-read)
await client.query(
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
[conversationId, user.id, userId],
)
const otherUser = await query(
const otherResult = await client.query(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
FROM users WHERE id = $1`,
[userId],
)
const other = otherUser.rows[0]
return { conversationId, other: otherResult.rows[0] }
})
const { conversationId, other } = result
return NextResponse.json({
conversation: {
@@ -119,6 +146,11 @@ export async function POST(request: NextRequest) {
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Converts a Date to a relative time string (e.g., "5m ago", "2h ago").
*/
function timeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (seconds < 60) return "now"
+139 -78
View File
@@ -1,8 +1,21 @@
// ── Dashboard ────────────────────────────────────────────────────────────────
// GET /api/dashboard?period=6months&year=2025
// — Returns aggregated stats, trends, monthly breakdown, and recent leads.
//
// Auth: authenticated; non-admin users scoped to their own leads
//
// Periods: 7days, 30days, 6months (default), 12months, or a specific year
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Returns the { start, end } Date range for a named period.
*/
function getPeriodDateRange(period: string): { start: Date; end: Date } {
const end = new Date()
let start: Date
@@ -20,6 +33,9 @@ function getPeriodDateRange(period: string): { start: Date; end: Date } {
return { start, end }
}
/**
* Returns the range for the *previous* period of the same length.
*/
function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } {
const end = new Date(currentStart)
const diff = end.getTime() - currentStart.getTime()
@@ -34,6 +50,9 @@ const periodLabels: Record<string, string> = {
"12months": "Last 12 months",
}
/**
* Maps DB stage name to client-facing status string.
*/
function stageToStatus(name: string): string {
switch (name) {
case "New": return "open"
@@ -48,74 +67,28 @@ function stageToStatus(name: string): string {
}
}
async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmin?: boolean) {
const result = await query(
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
l.notes, l.assigned_to, l.score,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
LEFT JOIN users u ON u.id = l.assigned_to
WHERE l.deleted_at IS NULL
AND l.created_at >= $1 AND l.created_at <= $2
${isAdmin ? "" : "AND l.assigned_to = $3"}
ORDER BY l.created_at DESC`,
isAdmin
? [start.toISOString(), end.toISOString()]
: [start.toISOString(), end.toISOString(), userId]
)
return result.rows.map((r: any) => ({
...r,
status: stageToStatus(r.stage_name),
}))
}
function countStatuses(leads: any[]) {
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
leads.forEach((l: any) => {
const s = l.status as keyof typeof counts
if (s in counts) counts[s]++
})
return counts
}
function buildMonthlyBreakdown(leads: any[], period: string) {
const { start, end } = getPeriodDateRange(period)
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
const current = new Date(start)
const isMonthly = period === "6months" || period === "12months"
while (current <= end) {
const label = isMonthly
? current.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
: current.toLocaleDateString("en-US", { month: "short", day: "numeric" })
const ps = new Date(current)
const pe = isMonthly
? new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59)
: (() => { const d = new Date(current); d.setHours(23, 59, 59, 999); return d })()
const inPeriod = leads.filter((l: any) => {
const d = new Date(l.created_at)
return d >= ps && d <= pe
})
const counts = countStatuses(inPeriod)
result.push({ label, total: inPeriod.length, ...counts })
if (isMonthly) current.setMonth(current.getMonth() + 1)
else current.setDate(current.getDate() + 1)
}
return result
}
/**
* Computes a trend object from current vs previous count.
*/
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
const pct = Math.round(((current - previous) / previous) * 100)
return { pct: Math.abs(pct), up: pct >= 0 }
}
// Reusable SQL snippet that maps stage names to status strings
const stageStatusSql = `
CASE
WHEN ls.name = 'New' THEN 'open'
WHEN ls.name = 'Contacted' THEN 'contacted'
WHEN ls.name IN ('Qualified', 'Interested', 'Demo Scheduled', 'Negotiation') THEN 'pending'
WHEN ls.name = 'Closed Won' THEN 'closed'
WHEN ls.name = 'Closed Lost' THEN 'ignored'
ELSE 'open'
END`
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -128,6 +101,7 @@ export async function GET(request: NextRequest) {
const yearParam = searchParams.get("year")
let start: Date, end: Date, prevRange: { start: Date; end: Date }
if (yearParam) {
// Year mode: compare full year to previous full year
const y = parseInt(yearParam)
start = new Date(y, 0, 1)
end = new Date(y, 11, 31, 23, 59, 59)
@@ -138,19 +112,108 @@ export async function GET(request: NextRequest) {
prevRange = getPreviousPeriodRange(period, start)
}
const [currentLeads, prevLeads] = await Promise.all([
fetchLeadsInRange(start, end, user.id, isAdmin),
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
])
// Non-admin users only see their own leads
const ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3"
const currentCounts = countStatuses(currentLeads)
const prevCounts = countStatuses(prevLeads)
// ── Status counts for current period (SQL aggregation) ──────────
const countSql = `
SELECT ${stageStatusSql} AS status, COUNT(*) AS count
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
WHERE l.deleted_at IS NULL
AND l.created_at >= $1 AND l.created_at <= $2
${ownerFilter}
GROUP BY ${stageStatusSql}`
const totalLeads = currentLeads.length
const currentCountRows = await query(
countSql,
isAdmin
? [start.toISOString(), end.toISOString()]
: [start.toISOString(), end.toISOString(), user.id],
)
const prevCountRows = await query(
countSql,
isAdmin
? [prevRange.start.toISOString(), prevRange.end.toISOString()]
: [prevRange.start.toISOString(), prevRange.end.toISOString(), user.id],
)
function rowsToCounts(rows: any[]) {
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
for (const r of rows) {
const s = r.status as keyof typeof counts
if (s in counts) counts[s] = parseInt(r.count, 10)
}
return counts
}
const currentCounts = rowsToCounts(currentCountRows.rows)
const prevCounts = rowsToCounts(prevCountRows.rows)
const totalLeads = currentCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0)
const prevTotal = prevCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0)
const closedLeads = currentCounts.closed
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
const mappedLeads = currentLeads.map((r: any) => ({
// ── Monthly/weekly breakdown via date_trunc ─────────────────────
const isMonthly = period === "6months" || period === "12months"
const truncUnit = isMonthly ? "month" : "day"
const breakdownSql = `
SELECT DATE_TRUNC($1, l.created_at) AS period_start,
${stageStatusSql} AS status,
COUNT(*) AS count
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
WHERE l.deleted_at IS NULL
AND l.created_at >= $2 AND l.created_at <= $3
${isAdmin ? "" : "AND l.assigned_to = $4"}
GROUP BY DATE_TRUNC($1, l.created_at), ${stageStatusSql}
ORDER BY period_start ASC`
const breakdownParams = isAdmin
? [truncUnit, start.toISOString(), end.toISOString()]
: [truncUnit, start.toISOString(), end.toISOString(), user.id]
const breakdownResult = await query(breakdownSql, breakdownParams)
// Aggregate the grouped rows into a label-keyed map
const breakdownMap: Record<string, { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }> = {}
for (const r of breakdownResult.rows) {
const d = new Date(r.period_start)
const label = isMonthly
? d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
: d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
if (!breakdownMap[label]) {
breakdownMap[label] = { label, total: 0, open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
}
const count = parseInt(r.count, 10)
breakdownMap[label].total += count
const statusKey = r.status as 'open' | 'contacted' | 'pending' | 'closed' | 'ignored'
breakdownMap[label][statusKey] = count
}
const monthlyBreakdown = Object.values(breakdownMap)
// ── Recent 10 leads ─────────────────────────────────────────────
const recentSql = `
SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
l.notes, l.assigned_to, l.score,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
LEFT JOIN users u ON u.id = l.assigned_to
WHERE l.deleted_at IS NULL
AND l.created_at >= $1 AND l.created_at <= $2
${ownerFilter}
ORDER BY l.created_at DESC
LIMIT 10`
const recentResult = await query(
recentSql,
isAdmin
? [start.toISOString(), end.toISOString()]
: [start.toISOString(), end.toISOString(), user.id],
)
const recentLeads = recentResult.rows.map((r: any) => ({
id: r.id,
companyName: r.company_name || "",
contactName: r.contact_name,
@@ -158,7 +221,7 @@ export async function GET(request: NextRequest) {
phone: r.phone || "",
source: "",
description: r.notes || "",
status: r.status,
status: stageToStatus(r.stage_name),
assignedUserId: r.assigned_to,
assignedUser: r.assigned_to ? {
id: r.user_id,
@@ -170,17 +233,15 @@ export async function GET(request: NextRequest) {
updatedAt: r.updated_at,
}))
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period)
// ── Trends (current vs previous period) ─────────────────────────
const trends = {
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
totalLeads: computeTrend(totalLeads, prevTotal),
openLeads: computeTrend(currentCounts.open, prevCounts.open),
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
conversionRate: computeTrend(conversionRate,
prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0),
prevTotal > 0 ? Math.round((prevCounts.closed / prevTotal) * 100) : 0),
}
const stats = {
@@ -192,9 +253,9 @@ export async function GET(request: NextRequest) {
ignoredLeads: currentCounts.ignored,
conversionRate,
monthlyBreakdown,
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
leadsPerMonth: monthlyBreakdown.map((m) => ({ label: m.label, leads: m.total, closed: m.closed })),
trends,
recentLeads: mappedLeads.slice(0, 10),
recentLeads,
statusDistribution: [
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
+8
View File
@@ -1,7 +1,15 @@
// ── Emails: Sent Log ─────────────────────────────────────────────────────────
// GET /api/emails — List the 50 most recently sent emails
//
// Auth: admin/super_admin only
// Used for auditing outbound email communications from the system.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
+14 -1
View File
@@ -1,15 +1,27 @@
// ── Event Users ─────────────────────────────────────────────────────────────
// GET /api/event-users — List all active users available for event assignment
//
// Auth: authenticated
// Returns users with their roles, excluding the current user (cannot assign
// events to yourself as a participant). Used by the calendar UI for dropdowns.
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
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
`SELECT u.id, u.first_name, u.last_name, u.email,
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.deleted_at IS NULL AND u.id != $1
ORDER BY u.first_name ASC`,
[user.id],
@@ -19,6 +31,7 @@ export async function GET() {
id: r.id,
name: `${r.first_name} ${r.last_name}`,
email: r.email,
role: r.role_display || r.role_name || "",
}))
return NextResponse.json({ users })
+10
View File
@@ -1,8 +1,16 @@
// ── Events: ICS Export ───────────────────────────────────────────────────────
// GET /api/events/[id]/ics — Export a calendar event as an .ics file
//
// Auth: authenticated; only the event creator can download the ICS
// Returns a text/calendar file for import into Outlook, Google Calendar, etc.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { buildIcs } from "@/lib/ics"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -10,6 +18,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
const { id } = await params
// Fetch event details with creator and participant info; enforce ownership
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,
@@ -28,6 +37,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
const r = result.rows[0]
// Build the ICS string via the shared utility
const icsContent = buildIcs({
uid: r.id,
title: r.title,
+54 -10
View File
@@ -1,19 +1,29 @@
// ── Events: Single Event ─────────────────────────────────────────────────────
// PATCH /api/events/[id] — Update an event (with permission checks & lead auto-close)
// DELETE /api/events/[id] — Delete an event (hard delete, creator only)
//
// Auth: authenticated; participants/developers can update limited fields;
// only the creator can delete or edit core event details.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventRescheduled } from "@/lib/email"
// ── PATCH ────────────────────────────────────────────────────────────────────
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 { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, developerId, participantNotes, clientName, clientEmail, clientPhone } = await request.json()
// Fetch current event with creator + participant details
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,
`SELECT e.user_id, e.participant_id, e.developer_id, e.lead_id, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.client_name, e.client_email, e.client_phone,
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
@@ -31,36 +41,44 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const old = existing.rows[0]
const isCreator = old.user_id === user.id
const isParticipant = old.participant_id === user.id
const isDeveloper = old.developer_id === user.id
if (!isCreator && !isParticipant) {
if (!isCreator && !isParticipant && !isDeveloper) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
// Non-creators cannot change core event details (title, type, time, participants)
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)
(participantId !== undefined && participantId !== old.participant_id) ||
(developerId !== undefined && developerId !== old.developer_id)
)) {
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
}
// COALESCE-based partial update: only overwrite provided fields
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),
start_time = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($5, start_time) END,
end_time = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($6, end_time) END,
duration_minutes = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($7, duration_minutes) END,
status = COALESCE($8, status),
participant_id = COALESCE($9, participant_id),
developer_id = COALESCE($10, developer_id),
client_name = COALESCE($11, client_name),
client_email = COALESCE($12, client_email),
client_phone = COALESCE($13, client_phone),
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`,
WHERE id = $14
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
[
title || null,
description ?? null,
@@ -71,6 +89,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
durationMinutes ?? null,
status || null,
participantId ?? null,
developerId ?? null,
clientName ?? null,
clientEmail ?? null,
clientPhone ?? null,
id,
],
user.id,
@@ -78,6 +100,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const r = result.rows[0]
// Auto-close the associated lead when a developer marks the event as completed
if (status === "completed" && r.lead_id) {
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Closed Won'")
if (stageResult.rows.length > 0) {
await query(
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
[stageResult.rows[0].id, r.lead_id],
)
}
}
// Send reschedule email if core fields changed
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
@@ -97,6 +131,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}).catch((err) => console.error("Reschedule email error:", err))
}
// Fetch creator info to return in the response
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
@@ -108,12 +143,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
)
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
const developerInfo = old.developer_id ? { id: old.developer_id, name: "", email: "" } : null
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
@@ -126,7 +163,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
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,
developer: developerInfo,
lead: null,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at,
},
})
@@ -136,6 +177,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
}
// ── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -153,6 +196,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
// Only the event creator can delete
if (existing.rows[0].user_id !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
+117 -57
View File
@@ -1,8 +1,17 @@
// ── Events: Collection ──────────────────────────────────────────────────────
// GET /api/events — List scheduled events (scoped to user's involvement)
// POST /api/events — Create a new event (with notifications & lead updates)
//
// Auth: authenticated
// GET supports filters: start, end (date range), status
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { query, transaction } from "@/lib/db"
import { sendEventConfirmation } from "@/lib/email"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -13,27 +22,35 @@ export async function GET(request: NextRequest) {
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,
// Build a large JOIN query that includes all associated user + lead info
let sql = `SELECT e.id, e.user_id, e.participant_id, e.developer_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,
e.client_name, e.client_email, e.client_phone,
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,
d.id AS d_id, d.first_name AS d_first, d.last_name AS d_last, d.email AS d_email, d.avatar_url AS d_avatar,
dr.role_id AS d_role_id, dr2.name AS d_role_name, dr2.display_name AS d_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 users d ON d.id = e.developer_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`
LEFT JOIN roles pr2 ON pr2.id = pr.role_id
LEFT JOIN user_roles dr ON dr.user_id = e.developer_id
LEFT JOIN roles dr2 ON dr2.id = dr.role_id`
const params: unknown[] = []
let idx = 1
// Non-super_admins only see events where they are creator, participant, or developer
if (user.role !== "super_admin") {
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}`
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx} OR e.developer_id = $${idx}`
params.push(user.id)
idx++
} else {
@@ -41,13 +58,13 @@ export async function GET(request: NextRequest) {
}
if (start) {
sql += ` AND e.start_time >= $${idx}`
sql += ` AND (e.start_time IS NULL OR e.start_time >= $${idx})`
params.push(start)
idx++
}
if (end) {
sql += ` AND e.start_time <= $${idx}`
sql += ` AND (e.start_time IS NULL OR e.start_time <= $${idx})`
params.push(end)
idx++
}
@@ -62,10 +79,12 @@ export async function GET(request: NextRequest) {
const result = await query(sql, params, user.id)
// Map rows to camelCase API shape with denormalized user/lead objects
const events = result.rows.map((r: any) => ({
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
@@ -78,7 +97,11 @@ export async function GET(request: NextRequest) {
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,
developer: r.d_id ? { id: r.d_id, name: `${r.d_first} ${r.d_last}`, email: r.d_email, role: r.d_role_display || r.d_role_name, avatar: r.d_avatar } : null,
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at,
}))
@@ -89,64 +112,108 @@ export async function GET(request: NextRequest) {
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const {
participantId, leadId, conversationId,
participantId, developerId, leadId, conversationId,
title, description, eventType,
startTime, endTime, durationMinutes,
clientName, clientEmail, clientPhone,
} = await request.json()
if (!title || !startTime) {
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 })
if (!title) {
return NextResponse.json({ error: "Title is required" }, { status: 400 })
}
// website_creation events don't require start time; all others do
if (eventType !== "website_creation" && !startTime) {
return NextResponse.json({ error: "Start time is required for this event type" }, { 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`,
// Single transaction for all DB operations
const result = await transaction(async (client) => {
// 1. Insert event
const ins = await client.query(
`INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
[
user.id,
participantId || null,
leadId || null,
conversationId || null,
title,
description || null,
eventType || "meeting",
startTime,
endTime || null,
durationMinutes || null,
user.id, participantId || null, developerId || null, leadId || null, conversationId || null,
title, description || null, eventType || "website_creation", startTime || null,
// For website_creation, endTime and durationMinutes are irrelevant
eventType === "website_creation" ? null : (endTime || null),
eventType === "website_creation" ? null : (durationMinutes || null),
clientName || null, clientEmail || null, clientPhone || null,
],
user.id,
)
const r = ins.rows[0]
const r = result.rows[0]
// 2. Auto-update lead stage to "Qualified" when a website creation event is scheduled
if (r.event_type === "website_creation" && leadId) {
const stageResult = await client.query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
if (stageResult.rows.length > 0) {
await client.query(
"UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL",
[stageResult.rows[0].id, leadId],
)
}
}
// 3. Batch notifications (multi-row INSERT)
const notifications: { user_id: string; type: string; title: string; description: string; link: string; context_id: string; context_type: string }[] = []
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",
],
notifications.push({
user_id: participantId, type: "event_scheduled",
title: `Meeting scheduled: ${title}`,
description: `${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
})
}
if (developerId && developerId !== user.id) {
notifications.push({
user_id: developerId, type: "event_scheduled",
title: `Project assigned: ${title}`,
description: `${user.firstName} ${user.lastName} assigned a project to you`,
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
})
}
if (notifications.length > 0) {
const placeholders = notifications.map((_, i) =>
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7})`
).join(", ")
const flatParams = notifications.flatMap(n => [n.user_id, n.type, n.title, n.description, n.link, n.context_id, n.context_type])
await client.query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) VALUES ${placeholders}`,
flatParams,
)
}
const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId],
) : null
const participant = participantResult?.rows[0] || null
// 4. Fetch participant + developer in one query
const idsToFetch = [participantId, developerId].filter(Boolean) as string[]
let usersMap: Record<string, { id: string; first_name: string; last_name: string; email: string }> = {}
if (idsToFetch.length > 0) {
const userPlaceholders = idsToFetch.map((_, i) => `$${i + 1}`).join(", ")
const userRows = await client.query(
`SELECT id, first_name, last_name, email FROM users WHERE id IN (${userPlaceholders})`,
idsToFetch,
)
for (const row of userRows.rows) {
usersMap[row.id] = row
}
}
return { r, usersMap }
}, user.id)
const { r, usersMap } = result
const participant = participantId ? usersMap[participantId] || null : null
const dev = developerId ? usersMap[developerId] || null : null
// Email sent asynchronously outside transaction (side effect)
sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
@@ -162,23 +229,16 @@ export async function POST(request: NextRequest) {
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,
id: r.id, userId: r.user_id, participantId: r.participant_id, developerId: r.developer_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, createdAt: r.created_at,
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,
participant: participant ? { id: participantId, name: `${participant.first_name} ${participant.last_name}`, email: participant.email } : null,
developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null,
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
createdAt: r.created_at,
clientName: r.client_name || null, clientEmail: r.client_email || null, clientPhone: r.client_phone || null,
},
}, { status: 201 })
} catch (error) {
+76 -28
View File
@@ -1,8 +1,19 @@
// ── GIFs: Search/Trending ────────────────────────────────────────────────────
// GET /api/gifs?q=&limit=&pos= — Search or get trending GIFs via GIPHY proxy
//
// Auth: authenticated
// Supports search queries and trending fallback with pagination.
// Gracefully degrades: returns empty results array on error or missing API key.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
const TENOR_API_KEY = process.env.TENOR_API_KEY || ""
const TENOR_BASE = "https://tenor.googleapis.com/v2"
// ── Constants ────────────────────────────────────────────────────────────────
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
@@ -12,45 +23,82 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const q = searchParams.get("q") || ""
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
const pos = searchParams.get("pos") || ""
const pos = parseInt(searchParams.get("pos") || "0", 10) || 0
if (!TENOR_API_KEY) {
return NextResponse.json({
results: [],
error: "TENOR_API_KEY not configured",
noKey: true,
})
if (!GIPHY_API_KEY) {
console.error("Missing GIPHY_API_KEY environment variable")
return NextResponse.json({ results: [], noKey: true })
}
const endpoint = q
? `${TENOR_BASE}/search?q=${encodeURIComponent(q)}&key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
: `${TENOR_BASE}/featured?key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
const url = pos ? `${endpoint}&pos=${pos}` : endpoint
let data: any
if (q) {
// Search mode
const url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(q)}&limit=${limit}&offset=${pos}&rating=g`
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
if (!res.ok) {
return NextResponse.json({ results: [], error: "Tenor API error" }, { status: 502 })
const errBody = await res.text()
console.error("GIPHY API search error:", res.status, errBody)
return NextResponse.json({ results: [], error: `GIPHY error: ${errBody}` }, { status: 502 })
}
data = await res.json()
} else {
// Trending mode with fallback to "funny" search if trending fails or is empty
const trendingUrl = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${pos}&rating=g`
const trendingRes = await fetch(trendingUrl, { signal: AbortSignal.timeout(8000) })
if (trendingRes.ok) {
data = await trendingRes.json()
if (!data.data?.length && pos === 0) {
console.log("Trending returned no results, falling back to default search")
const fallbackUrl = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=funny&limit=${limit}&offset=${pos}&rating=g`
const fallbackRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(8000) })
if (!fallbackRes.ok) {
const errBody = await fallbackRes.text()
console.error("GIPHY fallback search error:", fallbackRes.status, errBody)
return NextResponse.json({ results: [], error: `GIPHY error: ${errBody}` }, { status: 502 })
}
data = await fallbackRes.json()
}
} else {
const errBody = await trendingRes.text()
console.error("GIPHY trending error:", trendingRes.status, errBody)
console.log("Trending failed, falling back to default search")
const fallbackUrl = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=funny&limit=${limit}&offset=${pos}&rating=g`
const fallbackRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(8000) })
if (!fallbackRes.ok) {
const fallbackErr = await fallbackRes.text()
console.error("GIPHY fallback search error:", fallbackRes.status, fallbackErr)
return NextResponse.json({ results: [], error: `GIPHY error: ${fallbackErr}` }, { status: 502 })
}
data = await fallbackRes.json()
}
}
const data = await res.json()
const results = (data.results || []).map((item: any) => {
const media = item.media_formats?.gif || item.media_formats?.tinygif || {}
const preview = item.media_formats?.tinygif || item.media_formats?.gif || {}
// Normalize response to a minimal set of fields
const results = (data.data || []).map((item: any) => {
const dims = item.images?.original
return {
id: item.id,
title: item.title || "",
url: media.url || "",
previewUrl: preview.url || "",
width: media.dims?.[0] || 200,
height: media.dims?.[1] || 200,
url: dims?.url || "",
previewUrl: item.images?.fixed_width?.url || "",
width: dims?.width ? parseInt(dims.width, 10) : 200,
height: dims?.height ? parseInt(dims.height, 10) : 200,
}
})
return NextResponse.json({ results, next: data.next || "" })
} catch (error) {
console.error("GIF API error:", error)
return NextResponse.json({ results: [], error: "Failed to fetch GIFs" }, { status: 500 })
const nextOffset = pos + results.length
const total = data.pagination?.total_count || 0
const hasMore = total ? nextOffset < total : results.length === limit
const nextPos = hasMore ? String(nextOffset) : ""
return NextResponse.json({ results, next: nextPos })
} catch (error: any) {
console.error("GIF proxy error:", error)
const message = error?.message === "Failed to fetch"
? "Could not reach the GIF service."
: "Failed to fetch GIFs."
return NextResponse.json({ results: [], error: message }, { status: 500 })
}
}
+38 -3
View File
@@ -1,15 +1,49 @@
// ── Invite: Generate ─────────────────────────────────────────────────────────
// POST /api/invite/generate — Generate an invite link for a phone number
//
// Auth: super_admin only
// Rate-limited to 10 invites per hour. Creates a cryptographically random
// token and returns a full URL for sharing.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser, setSessionContext } from "@/lib/auth"
import { query } from "@/lib/db"
import crypto from "crypto"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const { phone, token: clientToken } = await request.json()
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only SUPER_ADMIN can generate invites." }, { status: 403 })
}
const { phone } = await request.json()
if (!phone) {
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
}
const token = clientToken || crypto.randomBytes(24).toString("hex")
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"127.0.0.1"
await setSessionContext(sessionUser.id, ipAddress)
// Rate limit: max 10 invites per rolling hour
const recentCount = await query(
`SELECT COUNT(*) AS cnt FROM invites WHERE created_at > NOW() - INTERVAL '1 hour'`,
)
if (parseInt(recentCount.rows[0]?.cnt || "0", 10) >= 10) {
return NextResponse.json({ error: "Rate limit exceeded. Try again later." }, { status: 429 })
}
// Generate a 48-character hex token using cryptographically secure random bytes
const token = crypto.randomBytes(24).toString("hex")
await query(
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
[token, phone],
@@ -19,7 +53,8 @@ export async function POST(request: NextRequest) {
const inviteUrl = `${origin}/join/${token}`
return NextResponse.json({ token, inviteUrl })
} catch {
} catch (error) {
console.error("Invite generation error:", error)
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
}
}
+17
View File
@@ -1,8 +1,20 @@
// ── Leads: Notes ─────────────────────────────────────────────────────────────
// POST /api/leads/[id]/notes — Add a note to a lead
// GET /api/leads/[id]/notes — List notes for a lead (paginated)
//
// Auth: authenticated users with access to the lead (owner or admin)
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Checks whether the requesting user has access to the given lead.
* Access is granted if the user is the assigned owner or has an admin role.
*/
async function checkLeadAccess(leadId: string, userId: string): Promise<boolean> {
const result = await query(
`SELECT 1 FROM leads WHERE id = $1 AND deleted_at IS NULL
@@ -15,6 +27,8 @@ async function checkLeadAccess(leadId: string, userId: string): Promise<boolean>
return result.rows.length > 0
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -43,6 +57,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
}
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -56,6 +72,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
// Fetch notes with author info, ordered newest-first
const result = await query(
`SELECT cn.id, cn.created_at, cn.updated_at, cn.content,
u.id AS user_id, u.first_name, u.last_name, u.avatar_url
+27 -3
View File
@@ -1,8 +1,19 @@
// ── Leads: Single Lead ──────────────────────────────────────────────────────
// GET /api/leads/[id] — Get a single lead by ID
// PATCH /api/leads/[id] — Update lead fields (dynamic partial update)
//
// Auth: all authenticated users; scoped to own assignments or admin
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Maps the DB stage name to the client-facing status string.
*/
function stageToStatus(name: string): string {
switch (name) {
case "New": return "open"
@@ -17,6 +28,8 @@ function stageToStatus(name: string): string {
}
}
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -25,6 +38,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const { id } = await params
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Fetch lead with stage + user JOINs; enforce ownership scoping
const result = await query(
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
@@ -70,6 +84,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Maps client-facing status back to the default DB stage name.
* Used when creating or updating leads via PATCH.
*/
function statusToStageName(status: string): string {
switch (status) {
case "open": return "New"
@@ -81,6 +101,8 @@ function statusToStageName(status: string): string {
}
}
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -89,7 +111,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const { id } = await params
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Verify access
// Verify the user has access to this lead before applying updates
const accessCheck = await query(
`SELECT id FROM leads WHERE id = $1 AND deleted_at IS NULL
AND ($2 = true OR assigned_to = $3)`,
@@ -104,6 +126,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const values: any[] = []
let idx = 1
// Build dynamic SET clause — only include provided fields
if (body.companyName !== undefined) { fields.push(`company_name = $${idx++}`); values.push(body.companyName) }
if (body.contactName !== undefined) { fields.push(`contact_name = $${idx++}`); values.push(body.contactName) }
if (body.email !== undefined) { fields.push(`email = $${idx++}`); values.push(body.email) }
@@ -111,15 +134,15 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
if (body.assignedUserId !== undefined) {
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Only admins can reassign leads
if (!isAdmin) {
// non-admin cannot reassign
return NextResponse.json({ error: "Only admins can reassign leads" }, { status: 403 })
}
fields.push(`assigned_to = $${idx++}`)
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
}
if (body.status !== undefined) {
// Resolve the client status → stage ID via the lookup table
const stageName = statusToStageName(body.status)
const stageResult = await query("SELECT id FROM lead_stages WHERE name = $1", [stageName])
if (stageResult.rows.length > 0) {
@@ -140,6 +163,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
// Always bump the updated_at timestamp
fields.push(`updated_at = NOW()`)
values.push(id)
+93 -39
View File
@@ -1,8 +1,21 @@
// ── Leads: Collection ────────────────────────────────────────────────────────
// GET /api/leads — List leads with search, filter, pagination
// POST /api/leads — Create a new lead
// DELETE /api/leads?id= — Soft-delete a lead
//
// Auth: all authenticated users; non-admins scoped to own assignments
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Maps the DB stage name to the client-facing status string.
* Used for consistent status labels across the app.
*/
function stageToStatus(name: string): string {
switch (name) {
case "New": return "open"
@@ -17,6 +30,10 @@ function stageToStatus(name: string): string {
}
}
/**
* Returns { start, end } Date range for a given period key.
* Returns null for "all" (no date filtering).
*/
function getPeriodDateRange(period: string): { start: Date; end: Date } | null {
if (period === "all") return null
const end = new Date()
@@ -30,6 +47,8 @@ function getPeriodDateRange(period: string): { start: Date; end: Date } | null {
return { start, end }
}
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -43,47 +62,80 @@ export async function GET(request: NextRequest) {
const offset = parseInt(searchParams.get("offset") || "0", 10)
const isAdmin = user.role === "admin" || user.role === "super_admin"
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
const params: any[] = []
let paramIdx = 1
// Base condition: exclude soft-deleted leads
const conditions: string[] = ["l.deleted_at IS NULL"]
// Apply date range filter if period is specified
if (period !== "all") {
const range = getPeriodDateRange(period)
if (range) {
conditions.push(`l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`)
params.push(range.start.toISOString(), range.end.toISOString())
paramIdx += 2
}
}
// Apply free-text search across name, company, email, phone (case-insensitive)
if (search) {
conditions.push(`(l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`)
params.push(`%${search}%`)
paramIdx++
}
// Non-admin users can only see their own assigned leads
if (!isAdmin) {
conditions.push(`l.assigned_to = $${paramIdx}`)
params.push(user.id)
paramIdx++
}
// Map client-facing status → DB stage names for server-side filtering
// This avoids pulling all rows and filtering client-side after pagination
const statusStageMap: Record<string, string[]> = {
open: ["New"],
contacted: ["Contacted"],
pending: ["Qualified", "Interested", "Demo Scheduled", "Negotiation"],
closed: ["Closed Won"],
ignored: ["Closed Lost"],
}
if (status !== "all") {
const stageNames = statusStageMap[status]
if (stageNames) {
const stagePlaceholders = stageNames.map((_, i) => `$${paramIdx + i}`)
conditions.push(`ls.name IN (${stagePlaceholders.join(", ")})`)
params.push(...stageNames)
paramIdx += stageNames.length
}
}
const whereClause = conditions.join(" AND ")
// Total count query (same filters, no pagination)
const countResult = await query(
`SELECT COUNT(*) AS total FROM leads l JOIN lead_stages ls ON ls.id = l.stage_id WHERE ${whereClause}`,
params.slice(0, paramIdx - 1),
)
const total = parseInt(countResult.rows[0]?.total || "0", 10)
// Paginated data query with JOINs for stage name and assigned user
const dataSql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
LEFT JOIN users u ON u.id = l.assigned_to
WHERE l.deleted_at IS NULL`
WHERE ${whereClause}
ORDER BY l.created_at DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
const dataParams = [...params, limit, offset]
const result = await query(dataSql, dataParams)
const params: any[] = []
let paramIdx = 1
if (period !== "all") {
const range = getPeriodDateRange(period)
if (range) {
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
params.push(range.start.toISOString(), range.end.toISOString())
paramIdx += 2
}
}
if (search) {
sql += ` AND (l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`
params.push(`%${search}%`)
paramIdx++
}
if (!isAdmin) {
sql += ` AND l.assigned_to = $${paramIdx}`
params.push(user.id)
paramIdx++
}
sql += ` ORDER BY l.created_at DESC`
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
params.push(limit, offset)
paramIdx += 2
const result = await query(sql, params)
let leads = result.rows.map((r: any) => {
// Shape rows into camelCase API response with denormalized assigned user
const leads = result.rows.map((r: any) => {
const s = stageToStatus(r.stage_name)
return {
id: r.id,
@@ -106,17 +158,15 @@ export async function GET(request: NextRequest) {
}
})
if (status !== "all") {
leads = leads.filter((l: any) => l.status === status)
}
return NextResponse.json(leads)
return NextResponse.json({ leads, total })
} catch (error) {
console.error("Leads API error:", error)
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -133,6 +183,7 @@ export async function POST(request: NextRequest) {
assignedUserId = null
}
// Look up the stage ID from the status name (default to "New" for open)
const stageResult = await query(
"SELECT id FROM lead_stages WHERE name = $1",
[body.status === "open" ? "New" : "Contacted"]
@@ -162,6 +213,8 @@ export async function POST(request: NextRequest) {
}
}
// ── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -173,6 +226,7 @@ export async function DELETE(request: NextRequest) {
const id = searchParams.get("id")
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
// Soft-delete by setting deleted_at; admins can delete any lead, others only their own
const result = await query(
"UPDATE leads SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL AND ($2 = true OR assigned_to = $3) RETURNING id",
[id, isAdmin, user.id]
+10
View File
@@ -1,7 +1,15 @@
// ── Notifications: Single Notification ──────────────────────────────────────
// PATCH /api/notifications/[id] — Mark a single notification as read
// DELETE /api/notifications/[id] — Dismiss/delete a single notification
//
// Auth: authenticated; only the owning user can modify their notifications
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -25,6 +33,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
}
// ── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -1,7 +1,16 @@
// ── Notification Preferences ─────────────────────────────────────────────────
// GET /api/notifications/preferences — Get current user's notification prefs
// PATCH /api/notifications/preferences — Upsert notification preferences
//
// Auth: authenticated
// Uses INSERT ... ON CONFLICT (user_id) DO UPDATE for idempotent upsert.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
@@ -14,6 +23,7 @@ export async function GET() {
[user.id],
)
// Return defaults if no row exists yet
if (result.rowCount === 0) {
return NextResponse.json({
leadAssigned: true,
@@ -38,6 +48,8 @@ export async function GET() {
}
}
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -45,6 +57,7 @@ export async function PATCH(request: NextRequest) {
const body = await request.json()
// Upsert: create row if not exists, otherwise update
await query(
`INSERT INTO notification_preferences (user_id, lead_assigned, lead_status, note_added, daily_digest, weekly_report, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
+16
View File
@@ -1,7 +1,16 @@
// ── Notifications: Collection ────────────────────────────────────────────────
// GET /api/notifications — List the current user's recent notifications
// POST /api/notifications — Create a new notification (self or target user)
// PATCH /api/notifications — Mark all unread notifications as read
//
// Auth: authenticated
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
@@ -28,6 +37,7 @@ export async function GET() {
contextType: r.context_type,
}))
// Also return total unread count for badge display
const unreadResult = await query(
`SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND is_read = FALSE`,
[user.id],
@@ -43,6 +53,8 @@ export async function GET() {
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -50,6 +62,7 @@ export async function POST(request: NextRequest) {
const { type, title, description, link, userId } = await request.json()
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Only admins can create notifications for other users; otherwise self-only
const targetUserId = (userId && isAdmin) ? userId : user.id
const result = await query(
@@ -75,11 +88,14 @@ export async function POST(request: NextRequest) {
}
}
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Mark all unread notifications as read for the current user
await query(
`UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND is_read = FALSE`,
[user.id],
+12
View File
@@ -1,7 +1,16 @@
// ── Settings: Company ───────────────────────────────────────────────────────
// GET /api/settings/company — Get company-wide settings
// PATCH /api/settings/company — Update company settings
//
// Auth: admin/super_admin only
// Falls back to sensible defaults if no row exists in company_settings.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
@@ -36,6 +45,8 @@ export async function GET() {
}
}
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -46,6 +57,7 @@ export async function PATCH(request: NextRequest) {
const body = await request.json()
// Attempt update on existing row; if no row exists, insert one
const result = await query(
`UPDATE company_settings SET
company_name = $1, company_email = $2, company_phone = $3,
@@ -1,7 +1,15 @@
// ── Settings: Single Facebook Account ───────────────────────────────────────
// PATCH /api/settings/facebook/accounts/[id]
// — Update a Facebook scraping account (label, profile path, active, unflag)
//
// Auth: admin/super_admin only
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -16,6 +24,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const values: any[] = []
let idx = 1
// Build dynamic SET clause for partial updates
if (body.isActive !== undefined) {
fields.push(`is_active = $${idx++}`)
values.push(body.isActive)
@@ -25,12 +34,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
values.push(body.label.trim())
}
if (body.profilePath !== undefined) {
// Update both profile_path and the derived cookie_file path
fields.push(`profile_path = $${idx++}, cookie_file = $${idx}`)
values.push(body.profilePath.trim())
values.push(`${body.profilePath.replace(/\\+$/, '')}\\cookies.sqlite`)
idx++
}
if (body.unflag === true) {
// Clear flag status and reset failure counter
fields.push(`flagged = FALSE, flagged_at = NULL, flagged_reason = NULL, consecutive_failures = 0`)
}
@@ -1,7 +1,15 @@
// ── Settings: Facebook Accounts ─────────────────────────────────────────────
// GET /api/settings/facebook/accounts — List all Facebook scraping accounts
// POST /api/settings/facebook/accounts — Add a new Facebook account
//
// Auth: admin/super_admin only
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
@@ -10,6 +18,7 @@ export async function GET() {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
// Fetch accounts with the latest scrape log result via LATERAL join
const accounts = await query(
`SELECT fa.id, fa.label, fa.profile_path, fa.is_active,
fa.last_scrape_at, fa.last_success_at, fa.last_error_at,
@@ -37,6 +46,8 @@ export async function GET() {
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -50,6 +61,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Label and profile path are required" }, { status: 400 })
}
// Derive the cookies file path from the profile path
const cookieFile = `${profilePath.replace(/\\+$/, '')}\\cookies.sqlite`
const result = await query(
`INSERT INTO facebook_accounts (label, profile_path, cookie_file)

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