Compare commits

..

26 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
183 changed files with 6930 additions and 878 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.git
*.md
.vscode
__pycache__
*.pyc
.DS_Store
.env.local
.env
.gitignore
.prettierrc
+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"]
+9
View File
@@ -1,6 +1,14 @@
// ── 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
@@ -16,6 +24,7 @@ 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
+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"]
+44 -35
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,38 +554,29 @@ 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" })
const { message, user_id, user_role } = body
if (!message) {
return sendJSON(res, 400, { error: "message is required" })
}
const validRoles = ["sales", "admin", "super_admin"]
if (user_role && !validRoles.includes(user_role)) {
return sendJSON(res, 403, { error: "Forbidden" })
}
const response = await handleChat(message, user_id || "", user_role || "sales")
sendJSON(res, 200, { response })
} catch (e) {
if (!res.headersSent) sendJSON(res, 500, { error: e.message })
}
})
return
}
// 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 })
}
// 404 fallback
sendJSON(res, 404, { error: "Not found" })
} catch (err) {
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
libdrm2 libdbus-1-3 libxkbcommon0 libxcomposite1 libxdamage1 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 \
libatspi2.0-0 libwayland-client0 libxshmfence1 && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install --with-deps firefox chromium 2>&1 | tail -5
COPY main.py ./
ENV PYTHONUNBUFFERED=1
EXPOSE 3008
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3008"]
+509 -207
View File
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3006", "http://127.0.0.1:3006"],
allow_origins=os.getenv("CORS_ORIGINS", "http://localhost:3006,http://127.0.0.1:3006").split(","),
allow_methods=["POST"],
allow_headers=["*"],
)
@@ -372,6 +372,7 @@ OFFER_PATTERNS = [
r"\bmy\s+portfolio\b",
r"\breasonable\s+(price|pricing|rate)\b",
r"\bwhatsapp\s+(me|us|at)\b",
r"\bwhatsapp\b",
r"\blooking\s+for\s+(a\s+)?(business|client|customer)\b",
r"\bhelp\s+your\s+business\b",
r"\bi\s+am\s+a\s+(web|freelance|designer|developer)\b",
@@ -407,6 +408,46 @@ OFFER_PATTERNS = [
r"\bselling\s+(my|a|the|this)\b",
r"\bpremium\b",
r"\bi'm\s+selling\b",
r"\bgroup\b",
r"\bi\s+can\s+help\b",
r"\binbox\s+me\b",
r"\bpm\s+me\b",
r"\bdm\s+me\b",
r"\bmessage\s+me\s+for\b",
r"\bquote\b",
r"\bdiscount\b",
r"\bbest\s+(deal|price|offer|service)\b",
r"\bprice\s+(start|begin|include|range)\b",
r"\breach\s+out\b",
r"\bclick\s+(the\s+)?link\b",
r"\bcheck\s+(out|this|my)\b",
r"\bwebsite\s+for\s+your\b",
r"\bprobono\b",
r"\bfreelance\s+opportunit",
r"\bfull.?stack\b",
r"\bresponsive\s+(web)?sites?\b",
r"\blooking\s+for\s+(new\s+)?(projects?|work)\b",
r"\bmern\s+stack\b",
r"\bexpress\s+js\b",
r"\breact\s+js\b",
r"\bnode\s+js\b",
r"\bwebsite\s+for\s+\$\b",
r"\bi\s+build\s+(websites?|shopify|wordpress)\b",
r"\bwe\s+build\s+(websites?|shopify|wordpress)\b",
r"\bi\s+create\s+(websites?|shopify|wordpress)\b",
r"\bwe\s+create\s+(websites?|shopify|wordpress)\b",
r"\bfull.?time\s+(position|job|role|work)\b",
r"\bremote\s+(position|job|role|work)\b",
r"\bhelp\s+wanted\b",
r"\bjob\s+(opening|vacancy|opportunity)\b",
r"\bnow\s+hiring\b",
r"\bpart.?time\s+(position|job|role|work)\b",
r"\byears?\s+of\s+(teaching|experience)\b",
r"\bsend\s+(you|me)\s+(the\s+)?link\b",
r"\bcomment\s+.*\bsend\b",
r"\bfor\s+free\b",
r"\bmake\s+money\b",
r"\bno\s+coding\b",
]
REQUEST_PATTERNS = [
@@ -625,13 +666,106 @@ TUTORING_SEARCHES = [
"tutor near me for",
]
def _search_list_for_query(query: str) -> list[str]:
"""Pick the appropriate search query pool based on the search term."""
tl = query.lower()
tutoring_terms = ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child", "math", "english", "science", "exam", "homeschool", "coding", "programming", "piano", "reading"]
if any(t in tl for t in tutoring_terms):
return TUTORING_SEARCHES
return FB_SEARCHES
# ── South African Multi-Language Queries ──────────────────────────────
# 4 SA languages grouped for phase-based scanning:
# Phase 1: English → Phase 2: Afrikaans → Phase 3: isiXhosa → Phase 4: English (final sweep)
# Each language group has dedicated queries per category.
SA_WEBSITE_QUERIES = {
"english": ["I need a website for my business"],
"afrikaans": ["ek benodig n webwerf", "ek soek iemand om n webwerf te bou"],
"xhosa": ["ndidinga iwebhusayithi yeshishini", "ndifuna umntu owakha iwebhusayithi"],
"zulu": ["ngidinga iwebhusayithi yebhizinisi", "ngifuna umuntu owakha iwebhusayithi"],
}
SA_TUTOR_QUERIES = {
"english": ["I need a tutor for my child"],
"afrikaans": ["ek benodig n tutor vir my kind", "ek soek n privaat onderwyser"],
"xhosa": ["ndifuna utitshala womntwana wam", "ndidinga umfundisi-ntsapho"],
"zulu": ["ngidinga uthisha wengane yami", "ngifuna umfundisi wengane"],
}
async def _quick_search(page, context, query: str) -> tuple:
"""Fast search — load search results page, wait for render, extract visible posts.
No scrolling or extra human-like delays. Used for non-English language queries."""
page = await _ensure_page(page, context)
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
try:
await page.goto(url, wait_until='domcontentloaded', timeout=20000)
current_url = page.url
if '/login' in current_url.lower():
logger.warning("Quick search redirected to login for '%s'", query[:40])
return page, []
await page.wait_for_timeout(random.randint(3000, 7000))
await page.evaluate(f"window.scrollBy(0, {random.randint(400, 900)})")
await page.wait_for_timeout(random.randint(2000, 5000))
if random.random() < 0.35:
await page.evaluate(f"window.scrollBy(0, -{random.randint(100, 400)})")
await page.wait_for_timeout(random.randint(1500, 3500))
await page.evaluate(f"window.scrollBy(0, {random.randint(400, 900)})")
await page.wait_for_timeout(random.randint(2000, 5000))
if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(1000, 3000))
raw_articles = await _get_article_elements(page)
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
raw = await page.evaluate('document.body.innerText')
text_posts = _extract_posts_from_text(raw, url)
existing = {(p.get('title') or p.get('content',''))[:80] for p in posts}
for tp in text_posts:
key = (tp.get('title') or tp.get('content',''))[:80]
if key not in existing:
posts.append(tp)
if posts:
try:
profiles = await page.evaluate(r'''() => {
const out = [];
const seenTxt = new Set();
for (const a of document.querySelectorAll('a[href*="/profile.php"], a[href*="/user/"], a[href*="/people/"], a[href*="/me/"]')) {
const name = (a.innerText || '').trim();
if (!name || name.length < 3 || name.length > 60) continue;
const words = name.split(' ');
if (words.length < 2 || words.length > 6) continue;
if (!/^[A-Z]/.test(name)) continue;
if (name.includes('facebook') || name.includes('/')) continue;
const cell = a.closest('div[style]') || a.parentElement;
const txt = cell ? (cell.innerText || '').substring(0, 200) : '';
if (!txt) continue;
const key2 = txt.substring(0, 80);
if (seenTxt.has(key2)) continue;
seenTxt.add(key2);
out.push({ name, textKey: key2 });
}
return out;
}''')
if profiles:
for p in posts:
pk = (p.get('content') or p.get('title') or '')[:80].strip()
if not pk: continue
for pr in profiles:
if pk[:30] in pr['textKey'] or pr['textKey'][:30] in pk:
if not p.get('author'):
p['author'] = pr['name']
break
except Exception:
pass
for p in posts:
if p.get('author'):
a = p['author']
al = a.lower()
if any(kw in al for kw in BROAD_KEYWORDS) or is_offer(a) or len(a.split()) < 2 or any(w in _NON_NAMES for w in al.split()):
p['author'] = ''
posts = [p for p in posts if not (
'/groups/' in p.get('url', '') or '/group/' in p.get('url', '')
or '/pages/' in p.get('url', '')
or ' / ' in (p.get('title') or p.get('content') or '')
)]
except Exception as e:
logger.warning("Quick search '%s' failed: %s", query, e)
return page, []
return page, posts
VIEWPORTS = [
{'width': 1280, 'height': 800},
@@ -723,7 +857,7 @@ def _parse_fb_date(block: list[str]) -> str:
return datetime.now().strftime('%Y-%m-%d')
def _is_within_days(date_str: str, max_days: int = 3) -> bool:
def _is_within_days(date_str: str, max_days: int = 2) -> bool:
"""Check if date is within max_days from now. Empty/unparseable = keep."""
if not date_str:
return True
@@ -748,7 +882,7 @@ def _clean_fb_text(text: str) -> str:
return '\n'.join(cleaned_lines)
# Words that should never be treated as person names
_NON_NAMES = {"online","tutor","tutoring","school","academy","learning","urgently","looking","south","africa","philippines","australia","creative","professional","digital","services","solutions","statistics","actuarial","homeschooling","homeschool","reading","math","english","science","grade","group","page","website","design","development","agency","company","studio","graphic","technical","support","customer","guest","post","fashion","wordpress","shopify","ecommerce","business","marketing","social","media","content","strategy","seo","free","domain","hosting","sponsored","promoted","advertisement","need","want","help","please","contact","send","message","whatsapp","hire","freelance","writer","blogger","expert","specialist","consultant","freelancer","software","creators","village","town","city","university","college","institute","evolve","aesthetics","sale","selling","premium","builder","pro","people","ecommerce"}
_NON_NAMES = {"online","tutor","tutoring","school","academy","learning","urgently","looking","south","africa","philippines","australia","creative","professional","digital","services","solutions","statistics","actuarial","homeschooling","homeschool","reading","math","english","science","grade","group","page","website","design","development","agency","company","studio","graphic","technical","support","customer","guest","post","fashion","wordpress","shopify","ecommerce","business","marketing","social","media","content","strategy","seo","free","domain","hosting","sponsored","promoted","advertisement","need","want","help","please","contact","send","message","whatsapp","hire","freelance","writer","blogger","expert","specialist","consultant","freelancer","software","creators","village","town","city","university","college","institute","evolve","aesthetics","sale","selling","premium","builder","pro","people","ecommerce","press","anonymous","tuition","parents","tutors","advanced","custom","fields","speed","optimization","elementor","woocommerce","acf","availability","january","february","march","april","may","june","july","august","september","october","november","december","mums","dads","uae","sharjah","dubai","abu","dhabi","hong","kong","canada","usa","united","kingdom","aunty","piano","plus","recommendations","needed"}
# ── Post Extraction ──────────────────────────────────────────────────
# Two strategies for extracting posts from page content:
@@ -756,10 +890,22 @@ _NON_NAMES = {"online","tutor","tutoring","school","academy","learning","urgentl
# 2. _extract_posts_from_text — fallback that parses raw page text line by line
# Both apply dedup (seen_texts), offer filtering, and request scoring.
_GROUP_SUFFIXES = [" - South Africa", " - Australia", " - Philippines", " PH", "Group", "help group", "info group", "public group", "private group", "group for", "community", "creators", "marketplace"]
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
posts = []
seen_texts = set()
now = datetime.utcnow()
for el in elements:
# Reject group posts immediately
if el.get('isGroup'):
continue
# Reject posts older than 2 days
ts = el.get('ts', 0)
if ts:
age_seconds = (now.timestamp() * 1000 - ts) / 1000
if age_seconds > 172800: # 2 days
continue
raw_text = el.get('text', '')
if len(raw_text) < 40:
continue
@@ -773,13 +919,23 @@ def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[di
if dekey in seen_texts:
continue
seen_texts.add(dekey)
# Reject if text contains group-like patterns
lower = text.lower()
if ' / ' in lower:
continue
skip = False
for suff in _GROUP_SUFFIXES:
if suff.lower() in lower:
skip = True
break
if skip:
continue
request_score = 2 if is_request(text) else 0
post_url = el.get('url') or base_url
# Prefer JS-extracted date, fall back to text parsing
js_date = (el.get('date') or '').strip()
if js_date:
# Try ISO datetime from <time datetime>
try:
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
date_str = dt.strftime('%Y-%m-%d')
@@ -883,7 +1039,6 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
if name:
p['author'] = name
# Remove group posts: any post whose text looks like it came from a group
_group_suffixes = [" - South Africa", " - Australia", " PH", "Group", "help group", "info group"]
filtered = []
for p in posts:
t = (p.get('title') or p.get('content') or '')
@@ -891,14 +1046,36 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
continue
lower = t.lower()
skip = False
for suff in _group_suffixes:
for suff in _GROUP_SUFFIXES:
if suff.lower() in lower:
skip = True
break
if skip:
continue
filtered.append(p)
return filtered
# Dedup: merge posts where one is a suffix of another (same post split)
merged = []
for p in filtered:
t = (p.get('title') or p.get('content') or '').lower()
# Check if this post is substantially contained in an already-merged post
found = False
for i, m in enumerate(merged):
mt = (m.get('title') or m.get('content') or '').lower()
# Word-level overlap: count shared words
twords = set(t.split())
mwords = set(mt.split())
if len(twords) > 3 and len(mwords) > 3:
overlap = len(twords & mwords)
smaller = min(len(twords), len(mwords))
if overlap / smaller >= 0.6:
# Keep the longer one
if len(t) > len(mt):
merged[i] = p
found = True
break
if not found:
merged.append(p)
return merged
# ── Human-like Behavior Simulation ──────────────────────────────────
# These functions add random delays, mouse movements, and scroll patterns
@@ -950,7 +1127,9 @@ async def _get_article_elements(page) -> list[dict]:
return await page.evaluate('''() => {
const results = [];
const seenTexts = new Set();
const terms = ["website","web design","web develop","need a","looking for","build my","create a","wordpress","landing page","ecommerce"];
const terms = ["website","web design","web develop","need a","looking for","build my","create a","wordpress","landing page","ecommerce","tutor","tutoring","homeschool","math","reading","english","science","grade"];
const now = Date.now();
const DAY_MS = 86400000;
const anchors = document.querySelectorAll('a[href*="/posts/"], a[href*="/story.php"], a[href*="/photo.php"], a[href*="/watch"], a[href*="/reel/"], a[href*="/permalink/"]');
for (const a of anchors) {
const h = a.getAttribute('href') || '';
@@ -966,15 +1145,22 @@ async def _get_article_elements(page) -> list[dict]:
const key = txt.substring(0, 80);
if (seenTexts.has(key)) continue;
seenTexts.add(key);
const isGroup = h.includes('/groups/');
let author = '';
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
if (nameEl) author = (nameEl.innerText || '').trim();
if (!author || author.length > 60 || author.length < 2) author = '';
let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
let date = '';
let ts = 0;
const timeEl = cell.querySelector('time');
if (timeEl) date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
results.push({ text: txt, author, url: postUrl, date });
if (timeEl) {
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
const dtVal = timeEl.getAttribute('datetime');
if (dtVal) { ts = new Date(dtVal).getTime(); }
}
results.push({ text: txt, author, url: postUrl, date, isGroup, ts });
}
return results;
}''')
@@ -1119,7 +1305,9 @@ async def search_facebook(page, context, query: str):
p['author'] = ''
# Final filter: strip any remaining group posts
posts = [p for p in posts if not (
'/groups/' in p.get('url', '') or ' / ' in (p.get('title') or p.get('content') or '')
'/groups/' in p.get('url', '') or '/group/' in p.get('url', '')
or '/pages/' in p.get('url', '')
or ' / ' in (p.get('title') or p.get('content') or '')
)]
except Exception as e:
logger.warning("Facebook search '%s' failed: %s", query, e)
@@ -1159,6 +1347,94 @@ def cleanup_chrome():
pass
# ── 4-Phase Language Pipeline ─────────────────────────────────────────
# Runs searches in ordered language phases:
# Phase 1: English (main query + supplementary EN searches)
# Phase 2: Afrikaans
# Phase 3: isiXhosa
# Phase 4: English (final sweep with different EN queries)
# Each phase extracts posts and deduplicates on the fly.
# Pipeline check (classify_leads) runs at end to quality-filter.
PHASE_ORDER = ["english", "afrikaans", "xhosa", "zulu"]
async def _run_phases(page, context, query: str | None = None) -> list[dict]:
"""4-phase language pipeline: English → Afrikaans → Xhosa → Zulu.
Each phase finds leads in that language. Pipeline check at end filters + sorts by freshness.
Returns top 10 newest, most relevant leads."""
all_posts: list[dict] = []
seen_keys: set[str] = set()
tutoring = False
if query:
tl = query.lower()
tutoring = any(t in tl for t in ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child"])
lang_pool = SA_TUTOR_QUERIES if tutoring else SA_WEBSITE_QUERIES
en_pool = TUTORING_SEARCHES if tutoring else FB_SEARCHES
# Build phase query lists
supplement = random.sample(en_pool, k=min(3, len(en_pool))) if query else []
phase_queries: list[list[str]] = []
# Phase 1: English (user query + supplement from EN pool)
phase_queries.append(([query] + supplement) if query else random.sample(en_pool, k=min(4, len(en_pool))))
# Phase 2: Afrikaans
phase_queries.append(lang_pool.get("afrikaans", []))
# Phase 3: isiXhosa
phase_queries.append(lang_pool.get("xhosa", []))
# Phase 4: isiZulu
phase_queries.append(lang_pool.get("zulu", []))
# Execute phases
for phase_idx, queries in enumerate(phase_queries):
if not queries:
continue
phase_posts: list[dict] = []
for i, q in enumerate(queries):
is_first = (phase_idx == 0 and i == 0 and query is not None)
if is_first:
page, posts = await search_facebook(page, context, q)
else:
page, posts = await _quick_search(page, context, q)
for p in posts:
key = p.get('content', '')[:100]
if key and key not in seen_keys:
seen_keys.add(key)
phase_posts.append(p)
all_posts.extend(phase_posts)
# Stealth delay between phases (not after last)
if phase_idx < len(phase_queries) - 1 and phase_posts:
await page.wait_for_timeout(random.uniform(5000, 12000))
if random.random() < 0.2:
await random_idle(page)
# Pipeline check: date filter (2 days max) + AI/keyword classification
all_posts = [p for p in all_posts if _is_within_days(p.get('date', ''), 2)]
leads = all_posts[:20]
if leads:
leads = await classify_leads(leads, tutoring=tutoring)
# Sort by freshness — newest leads first
def _sort_key(l):
try:
return datetime.strptime((l.get('date') or '').strip()[:10], '%Y-%m-%d')
except (ValueError, IndexError):
return datetime.min
leads.sort(key=_sort_key, reverse=True)
return leads[:10]
# ── Main Scrape Dispatcher ────────────────────────────────────────────
# scrape_facebook() is the main entry point. It:
# 1. Resolves the browser profile path (from SELECTED_BROWSER env var or auto-detect)
@@ -1213,17 +1489,17 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False,
# Firefox path
if browser_type == "firefox":
result = await _scrape_with_firefox(effective_path, force, query)
if result.get("success") or not result.get("flagged"):
if result.get("success"):
return result
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force)
logger.warning("Firefox failed (reason: %s), trying Agent", result.get("flag_reason") or result.get("error", "unknown"))
return await _scrape_with_agent(force, query)
# Chromium-based (chrome / opera / edge)
result = await _scrape_with_chromium(effective_path, browser_type, force, query)
if result.get("success") or not result.get("flagged"):
if result.get("success"):
return result
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force)
logger.warning("%s failed (reason: %s), trying Agent", browser_type, result.get("flag_reason") or result.get("error", "unknown"))
return await _scrape_with_agent(force, query)
# ── Firefox Scraper ──────────────────────────────────────────────────
@@ -1246,6 +1522,7 @@ async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None
context = await pw.firefox.launch_persistent_context(
user_data_dir=profile_dir,
headless=True,
viewport=random.choice(VIEWPORTS),
firefox_user_prefs={
"dom.webdriver.enabled": False,
"dom.webdriver.timeout": 0,
@@ -1265,74 +1542,35 @@ async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None
except Exception:
logger.warning("Google navigation failed, trying Facebook directly")
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
try:
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
url = page.url
page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else ''
det = check_detection_signals(url, page_text)
if det or '/login' in url.lower():
logger.warning("Facebook login page detected — flag: %s", det or "login_page")
await context.close()
return {"success": False, "leads": [], "flagged": True, "flag_reason": det or "login_page", "error": "Facebook login page detected"}
url = page.url
page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else ''
det = check_detection_signals(url, page_text)
if det or '/login' in url.lower():
logger.warning("Facebook login page detected — flag: %s", det or "login_page")
return {"success": False, "leads": [], "flagged": True, "flag_reason": det or "login_page", "error": "Facebook login page detected"}
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000))
await human_scroll(page, steps=random.randint(1, 2))
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000))
await human_scroll(page, steps=random.randint(1, 2))
if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000))
await context.close()
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000))
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = []
if query:
query_pool = _search_list_for_query(query)
searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
else:
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, sq in enumerate(searches):
page, posts = await search_facebook(page, context, sq)
all_posts.extend(posts)
if not posts:
continue
if random.random() < 0.4:
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
delay = random.uniform(8, 25)
await page.wait_for_timeout(int(delay * 1000))
if i == random.randint(0, 1) and random.random() < 0.15:
new_page = await context.new_page()
try:
await new_page.goto('https://www.facebook.com/groups/', wait_until='domcontentloaded', timeout=15000)
await new_page.wait_for_timeout(random.randint(3000, 8000))
except Exception:
pass
await new_page.close()
page = await _ensure_page(page, context)
leads = await _run_phases(page, context, query)
if random.random() < 0.5:
await page.wait_for_timeout(random.randint(3000, 10000))
await context.close()
seen = set()
deduped = []
for p in all_posts:
key = p.get('content', '')[:100]
if key not in seen:
seen.add(key)
deduped.append(p)
# Filter to last 3 days only
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 3)]
leads = deduped[:20]
if leads:
leads = await classify_leads(leads)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
finally:
try:
await context.close()
except Exception:
pass
except Exception as e:
logger.error("Firefox scrape failed: %s", e)
@@ -1382,6 +1620,7 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
launch_kwargs = dict(
user_data_dir=profile_dir,
headless=True,
viewport=random.choice(VIEWPORTS),
args=CHROME_LAUNCH_ARGS,
)
if channel:
@@ -1400,72 +1639,35 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
except Exception:
logger.warning("Google navigation failed, trying Facebook directly")
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
try:
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
url = page.url
page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else ''
det = check_detection_signals(url, page_text)
if det or '/login' in url.lower():
logger.warning("Facebook login page detected — flag: %s", det or "login_page")
await context.close()
return {"success": False, "leads": [], "flagged": True, "flag_reason": det or "login_page", "error": "Facebook login page detected"}
url = page.url
page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else ''
det = check_detection_signals(url, page_text)
if det or '/login' in url.lower():
logger.warning("Facebook login page detected — flag: %s", det or "login_page")
return {"success": False, "leads": [], "flagged": True, "flag_reason": det or "login_page", "error": "Facebook login page detected"}
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000))
await human_scroll(page, steps=random.randint(1, 2))
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000))
await human_scroll(page, steps=random.randint(1, 2))
if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000))
await context.close()
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000))
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = []
if query:
query_pool = _search_list_for_query(query)
searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
else:
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, sq in enumerate(searches):
page, posts = await search_facebook(page, context, sq)
all_posts.extend(posts)
if not posts:
continue
if random.random() < 0.4:
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
delay = random.uniform(8, 25)
await page.wait_for_timeout(int(delay * 1000))
if i == random.randint(0, 1) and random.random() < 0.15:
new_page = await context.new_page()
try:
await new_page.goto('https://www.facebook.com/groups/', wait_until='domcontentloaded', timeout=15000)
await new_page.wait_for_timeout(random.randint(3000, 8000))
except Exception:
pass
await new_page.close()
page = await _ensure_page(page, context)
leads = await _run_phases(page, context, query)
if random.random() < 0.5:
await page.wait_for_timeout(random.randint(3000, 10000))
await context.close()
seen = set()
deduped = []
for p in all_posts:
key = p.get('content', '')[:100]
if key not in seen:
seen.add(key)
deduped.append(p)
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 3)]
leads = deduped[:20]
if leads:
leads = await classify_leads(leads)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
finally:
try:
await context.close()
except Exception:
pass
except Exception as e:
logger.error("%s scrape failed: %s", browser, e)
@@ -1485,7 +1687,7 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
# Uses Chromium headless with the same launch args as _scrape_with_chromium.
# The Agent is prompted to extract structured post data and return JSON.
async def _scrape_with_agent(force: bool = False) -> dict:
async def _scrape_with_agent(force: bool = False, query: str | None = None) -> dict:
"""Fallback scraper — browser-use Agent + ChatOllama (free/local, Chromium)."""
cleanup_chrome()
profile_dir = None
@@ -1504,7 +1706,14 @@ async def _scrape_with_agent(force: bool = False) -> dict:
await browser.start()
all_posts = []
for query in random.sample(FB_SEARCHES, k=random.randint(2, 4)):
tutoring_agent = False
if query:
tl = query.lower()
tutoring_agent = any(t in tl for t in ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child"])
sa_dict = SA_TUTOR_QUERIES if tutoring_agent else SA_WEBSITE_QUERIES
sa_all = sa_dict.get("afrikaans", []) + sa_dict.get("xhosa", []) + sa_dict.get("zulu", [])
pool = FB_SEARCHES + sa_all
for query in random.sample(pool, k=random.randint(2, 4)):
agent = _make_agent(
task=f"""You are logged into Facebook. Do the following:
1. Navigate to facebook.com and make sure you are on the homepage
@@ -1515,7 +1724,7 @@ async def _scrape_with_agent(force: bool = False) -> dict:
- The post text content
- The post URL (if visible)
- The post date
5. ONLY include posts from the last 3 days
5. ONLY include posts from the last 2 days
6. Collect as many posts as you can (aim for 5-10 per search)
When done, return the data as a JSON list with keys: content, author, url, date.""",
@@ -1544,12 +1753,12 @@ When done, return the data as a JSON list with keys: content, author, url, date.
seen.add(key)
deduped.append(p)
# Filter to last 3 days only
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 3)]
# Filter to last 2 days only
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 2)]
leads = deduped[:20]
if leads:
leads = await classify_leads(leads)
leads = await classify_leads(leads, tutoring=tutoring_agent)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
except Exception as e:
@@ -1584,22 +1793,37 @@ async def ask_ollama(prompt: str) -> str:
data = r.json()
return data["message"]["content"]
async def classify_leads(results: list[dict]) -> list[dict]:
async def classify_leads(results: list[dict], tutoring: bool = False) -> list[dict]:
if not results:
return []
# ── 1. AI classification ─────────────────────────────────────────
briefs = [r["title"][:200] for r in results]
briefs = [(r.get("title") or r.get("content") or "")[:200] for r in results]
if tutoring:
lead_desc = "someone REQUESTING/LOOKING FOR/WANTING a tutor, teacher, or lessons for their child or themselves"
lead_examples = '"Looking for a tutor for my child", "Need a math tutor for my son", "Need help with homework", "Looking for piano lessons for my daughter", "Need a reading tutor"'
not_lead_examples = '"I offer tutoring services", "I am a tutor with experience", "Affordable tutoring packages", "Online tutor available"'
extra_terms = '- Posts about homeschooling resources, curriculum sales, or educational products\n- Posts asking for study tips or general academic advice without requesting a tutor'
else:
lead_desc = "someone REQUESTING/POSTING/WANTING a website built, designed, or created for them"
lead_examples = '"Need a website for my business", "Looking for web developer to build my site", "I need someone to create my website", "Want a new website for my company", "Looking for someone to design my WordPress site"'
not_lead_examples = '"I build websites", "I offer web design", "Affordable web design packages"'
extra_terms = '- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"'
prompt = f"""Classify each post as LEAD or NOT.
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
LEAD examples: "Need a website for my business", "Looking for web developer to build my site", "I need someone to create my website", "Want a new website for my company", "Looking for someone to design my WordPress site"
LEAD = {lead_desc}.
LEAD examples: {lead_examples}
NOT LEAD:
- Offering web design services: "I build websites", "I offer web design", "Affordable web design packages"
- Offering services: {not_lead_examples}
- Already have a website and need marketing, SEO, content, video, link building, email marketing, affiliates
- Recruiting employees, hiring staff, looking for business partners
- Selling products, promoting services, affiliate offers
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
{extra_terms}
- Posts from groups, communities, or pages (group announcements, group posts, page posts)
- Posts containing the word "group", "page", "community", "creators" — these are NEVER individual leads
- Vague questions or general recommendations without a clear intent to buy or hire
- People asking how to learn or do it themselves (not looking to hire someone)
- Posts about existing website issues like speed, SEO, errors, redesign advice — NOT a lead
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
@@ -1624,32 +1848,70 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
except Exception as e:
logger.warning("AI classification failed: %s", e)
# ── 2. Keyword fallback (always runs) ────────────────────────────
web_terms = [
"website", "web design", "web develop", "web dev",
"web designer", "web developer",
"build my website", "build a website", "create a website",
"landing page", "wordpress", "ecommerce",
"my website", "business website",
"site for my", "site for my business",
"new website", "redesign my website",
"help with my website", "update my website",
"make a website", "make my website",
"website for my",
"online store", "online shop",
"build my site", "build a site",
"set up a website", "set up my website",
"custom website",
"shopify",
"my site",
"webpage", "web page",
]
# ── 2. Keyword supplement (never overrides AI, only adds missing leads) ──
if tutoring:
target_terms = [
"tutor", "tutoring", "tutor for", "private tutor",
"math tutor", "english tutor", "reading tutor",
"science tutor", "online tutor", "home tutor",
"lessons for", "lessons for my", "piano lessons",
"swimming lessons", "music lessons",
"help with homework", "homework help",
"teacher for", "teacher for my",
"need help learning", "need help with",
"exam prep", "exam preparation",
"homeschool", "homeschool tutor",
"tuition",
"coding for my", "programming for my",
"looking for a tutor", "need a tutor",
"tutor needed", "tutoring for",
"private lessons", "private tuition",
"afterschool", "after school",
"extra classes", "extra lessons",
]
offer_reject_tutor = [
'i am a tutor', "i'm a tutor", 'i offer tutoring',
'online tutor available', 'tutor available',
'i teach', 'i provide tutoring',
'affordable tutoring', 'tutoring services',
'experienced tutor', 'qualified tutor',
'your child', 'your kids', 'your children',
'enroll your', 'sign up',
'free trial', 'first lesson free',
'group lessons', 'group class',
'limited spots', 'book now',
'curriculum', 'workbook', 'worksheet',
'educational program',
'homeschool program', 'home school program',
]
else:
target_terms = [
"website", "web design", "web develop", "web dev",
"web designer", "web developer",
"build my website", "build a website", "create a website",
"landing page", "wordpress", "ecommerce",
"my website", "business website",
"site for my", "site for my business",
"new website", "redesign my website",
"help with my website", "update my website",
"make a website", "make my website",
"website for my",
"online store", "online shop",
"build my site", "build a site",
"set up a website", "set up my website",
"custom website",
"shopify",
"my site",
"webpage", "web page",
"who can build", "who can design",
"create my website", "create my site",
]
offer_reject_tutor = []
request_terms = [
"looking for", "need a", "need an", "looking to",
"need someone", "hire a", "want someone",
"need help with", "would like", "build me",
"design my", "make me a", "create my",
"looking", "need", "want", "help",
"who can", "i need",
"recommend", "anyone know", "anyone recommend",
"know a", "know any", "recommendation",
@@ -1671,55 +1933,95 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
'whatsapp me', 'looking for a business', 'looking for client',
'help your business', 'i am a web', 'contact me',
'we offer web', 'we provide web',
'take the quiz', 'homeschool', 'your home tutor',
'take the quiz',
'link in bio', 'apply now', 'get started',
'for only', 'low price', 'hit me up',
'send me a message', 'i do website', 'we do website',
'we do web', 'i do web',
'website designer / web developer', 'website & software creators',
'website builders for small businesses', 'australia web designers',
'south africa', 'wix website design',
'website builders for small businesses',
'wix website design',
'for sale', 'selling my', 'premium',
'i\'m selling', 'i\'m offering', 'we\'re offering',
'free ecommerce', 'free website design',
'starting a', 'looking for a few businesses',
# Group-related rejections
'group', ' i need a website group',
'i can help', 'inbox me', 'message me for',
'best price', 'discount', 'reach out', 'check out my', 'check this',
'website for your', 'price start', 'price begin', 'website creator',
'website & software', 'creators &', 'creators marketplace',
'website group', 'page group',
'south africa web', 'philippines web', 'australia web',
'nigerian web', 'kenya web', 'india web',
# Self-promotion rejections
'i\'m a web', "i'm a web", 'i am a full stack', "i'm a full stack",
'freelance opportunity', 'looking for new project', 'looking for new work',
'full stack web', 'mern stack', 'responsive business website',
'i build website', 'i build shopify', 'i build wordpress',
'we build website', 'we build shopify', 'we build wordpress',
'i create website', 'we create website',
'full time position', 'full time job', 'remote position', 'remote job',
'help wanted', 'now hiring', 'job opening',
'website speed', 'speed optimization', 'on page seo', 'off page seo',
'elementor pro', 'woocommerce', 'acf', 'custom post type',
'send you the link', 'comment website',
'for free', 'no coding', 'make money', 'website for free',
'part time job', 'part time position',
'years of experience', 'years of teaching',
# Service offers that slip through two-word check
'i am a full stack', 'i am a developer',
'i will design', 'i will build', 'i will create',
'i can design', 'i can create',
'we will design', 'we will build',
'hire me', 'i am available for',
'available for work', 'freelance web',
'i specialize in', 'we specialize in',
"here's my portfolio", 'check my portfolio',
'see my work', 'view my work',
'we have a team', 'my team',
'i am looking for clients', 'i am looking for work',
'looking for web development work',
'looking for new clients',
# People learning / doing it themselves (not hiring)
'learn web development', 'learn to code',
'how to build a website', 'how to create a website',
'how to make a website', 'how to design a website',
'where to start', 'online course',
'want to learn', 'learning web',
'best platform for', 'which platform',
# Existing website issues (not new build)
'my website is down', 'website not loading',
'website error', 'website problem',
'website troubleshooting',
'need website advice', 'website tips',
'help with seo', 'google ranking',
'website design ideas', 'website inspiration',
]
for r in results:
t = r['title'].lower()
has_web = any(kw in t for kw in web_terms)
t = (r.get('title') or r.get('content') or '').lower()
has_target = any(kw in t for kw in target_terms)
has_request = any(kw in t for kw in request_terms)
if not has_web or not has_request:
if not has_target or not has_request:
continue
if any(kw in t for kw in offer_reject):
continue
if any(kw in t for kw in offer_reject_tutor):
continue
keyword_leads.append(r)
# ── 3. Merge: prefer AI leads, supplement with keywords to reach 5 ──
seen_titles: set[int] = set()
# ── 3. Merge: prefer AI leads, supplement with keywords ──
seen_titles: set[str] = set()
merged: list[dict] = []
for r in ai_leads + keyword_leads:
key = hash(r.get('title', ''))
if key not in seen_titles:
key = (r.get('title') or '').strip()[:200]
if key and key not in seen_titles:
seen_titles.add(key)
merged.append(r)
# Final sweep: strip any remaining offers or group posts from merged
group_words = ['group', ' groups', 'page:', 'page |', 'community', 'creators', 'marketplace']
merged = [r for r in merged if not any(kw in (r.get('title','') or '').lower() for kw in offer_reject)]
# Fill to 5 with loose keyword matches (at least web OR request term)
if len(merged) < 5:
for r in results:
key = hash(r.get('title', ''))
if key in seen_titles:
continue
t = r['title'].lower()
if not (any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms)):
continue
if any(kw in t for kw in offer_reject):
continue
seen_titles.add(key)
merged.append(r)
if len(merged) >= 5:
break
merged = [r for r in merged if not any(gw in (r.get('title','') or '').lower() for gw in group_words)]
logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results))
return merged[:10]
+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
+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;
+7
View File
@@ -75,5 +75,12 @@ BEGIN;
\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
+19 -15
View File
@@ -6,27 +6,29 @@
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:signaling": "node signaling-server.mjs",
"dev:open": "node scripts/open-browser.mjs",
"dev:repair": "node scripts/code-repair-agent.mjs --watch",
"dev:start": "concurrently -n REPAIR,AI,BROWSE,SIGNAL,NEXT,OPEN -c red,cyan,magenta,yellow,green,white \"npm run dev:repair\" \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
"dev:next": "next dev -p 3006",
"dev:precheck": "node scripts/precheck.mjs",
"dev:ollama": "node scripts/ensure-ollama.mjs",
"dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
"setup": "node scripts/setup.mjs",
"setup:self-heal": "node scripts/setup.mjs --self-heal",
"repair": "node scripts/code-repair-agent.mjs",
"repair:watch": "node scripts/code-repair-agent.mjs --watch",
"repair:ci": "node scripts/code-repair-agent.mjs --ci",
"build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci",
"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",
@@ -48,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",
@@ -69,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
}
+17 -1
View File
@@ -28,19 +28,23 @@ 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 })
@@ -50,6 +54,7 @@ function runTsc() {
}
}
/** 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
@@ -66,6 +71,7 @@ function parseErrors(output) {
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")
@@ -83,6 +89,7 @@ function readContext(filePath, errorLine, contextLines = 20) {
// ── 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",
@@ -99,6 +106,7 @@ async function queryOllama(prompt) {
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.
@@ -122,6 +130,7 @@ Rules:
- 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)
@@ -133,6 +142,7 @@ function parseFixResponse(text) {
}
}
/** 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" }
@@ -147,8 +157,8 @@ function applyFix(filePath, fixSuggestion) {
// ── Breaking Change Escalation ─────────────────────────────────────
/** Heuristic check: if any fix touches imports, exports, or deletions, consider it a breaking change. */
function isBreakingChange(errors, fixes) {
// Heuristic: if fix touches import structure or exports, it's breaking
for (const fix of fixes) {
if (!fix || !fix.fix) continue
if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) {
@@ -158,6 +168,7 @@ function isBreakingChange(errors, fixes) {
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()
@@ -181,6 +192,7 @@ async function escalateBreakingChange(errors, fixes) {
// ── 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 })
@@ -195,6 +207,7 @@ function gitCommit(message) {
// ── 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()
@@ -276,6 +289,7 @@ async function repairOnce(doCommit = false) {
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} ===`)
@@ -293,8 +307,10 @@ async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) {
// ── 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")
+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")
+19 -10
View File
@@ -1,6 +1,8 @@
// ── 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";
@@ -12,6 +14,7 @@ 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;
@@ -20,6 +23,7 @@ export interface TemplateRegistry {
};
}
/** Result of a single module generation attempt. */
export interface GenerationResult {
success: boolean;
filePath: string;
@@ -28,18 +32,21 @@ export interface GenerationResult {
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 */
/** 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",
@@ -49,6 +56,7 @@ export function loadRegistry(): { templates: TemplateRegistry; fallbacks: Fallba
"@/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");
@@ -64,10 +72,10 @@ export function loadRegistry(): { templates: TemplateRegistry; fallbacks: Fallba
return { templates: builtInTemplates, fallbacks: builtInFallbacks };
}
/** Match an internal path against fallback patterns */
/** 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 to regex
// Convert glob pattern (e.g. "@/hooks/*") to a regular expression
const regexStr = "^" + pattern
.replace(/\//g, "\\/")
.replace(/\./g, "\\.")
@@ -82,7 +90,7 @@ export function matchFallback(internalPath: string, fallbacks: FallbackRegistry)
return undefined;
}
/** Render a template with parameters */
/** 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)) {
@@ -92,7 +100,7 @@ function renderTemplate(templateContent: string, params: Record<string, unknown>
return result;
}
/** Get template content - first from custom templates dir, then built-in */
/** 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);
@@ -100,7 +108,7 @@ function getTemplateContent(templateName: string): string {
return fs.readFileSync(customPath, "utf-8");
}
// Built-in templates (embedded)
// 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.
@@ -312,16 +320,16 @@ export interface DashboardStats {
return builtInTemplates[templateName] || "";
}
/** Generate a single missing module */
/** 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;
// Try fallback patterns
if (!entry) {
const fallbackTemplate = matchFallback(internalPath, fallbacks);
if (fallbackTemplate) {
@@ -349,10 +357,11 @@ export function generateModule(
};
}
// 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
// Ensure directory exists before writing
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
@@ -377,7 +386,7 @@ export function generateModule(
}
}
/** Generate all missing modules for a list of internal paths */
/** 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[] = [];
+4 -3
View File
@@ -2,6 +2,7 @@
// 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" */
@@ -20,7 +21,7 @@ export interface MissingModule {
internalPath?: string;
}
/** Parse TypeScript compiler output for missing module errors */
/** Parse TypeScript compiler output for missing module errors (TS2307 + webpack-style "Can't resolve"). */
export function parseMissingModules(tscOutput: string): MissingModule[] {
const results: MissingModule[] = [];
@@ -74,12 +75,12 @@ export function parseMissingModules(tscOutput: string): MissingModule[] {
});
}
/** Filter to only internal (@/...) modules we can auto-generate */
/** 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 internal path */
/** 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) {
+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") {
+5 -4
View File
@@ -2,10 +2,9 @@
// Verifies all packages listed in package.json are installed.
// Auto-runs npm install if any are missing — zero manual setup steps.
import { readFileSync } from "node:fs"
import { readFileSync, existsSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
import { execSync } from "node:child_process"
import { platform } from "node:os"
@@ -13,12 +12,13 @@ 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 require = createRequire(import.meta.url)
const missing = Object.keys(allDeps).filter(name => {
try { require.resolve(name, { paths: [root] }); return false } catch { return true }
const pkgDir = resolve(root, "node_modules", name)
return !existsSync(resolve(pkgDir, "package.json"))
})
if (missing.length > 0) {
@@ -46,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))
+22 -58
View File
@@ -15,12 +15,14 @@ import { platform } from "node:os"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
// Shell command separator differs per platform (Windows uses &, POSIX uses ;)
const SEP = platform() === "win32" ? "&" : ";"
const SELF = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SELF, "..")
// ── Helpers ────────────────────────────────────────────────────────
/** Try known Python command names until one succeeds, then exit if none found. */
function detectPython() {
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) {
@@ -33,6 +35,7 @@ function detectPython() {
process.exit(1)
}
/** Detect pip; fall back to `python -m pip` if no standalone pip binary exists. */
function detectPip(python) {
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
for (const cmd of candidates) {
@@ -44,6 +47,7 @@ function detectPip(python) {
return `${python} -m pip`
}
/** Run a shell command with label output. Exits on failure unless opts.optional is set. */
function run(cmd, label, opts = {}) {
console.log(`\n── ${label} ──`)
try {
@@ -60,11 +64,15 @@ function run(cmd, label, opts = {}) {
}
// ── 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" },
@@ -75,6 +83,7 @@ function loadRegistry() {
"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",
@@ -87,6 +96,7 @@ function loadRegistry() {
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"))
@@ -97,6 +107,7 @@ function loadRegistry() {
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 = [
@@ -113,6 +124,7 @@ function loadRegistry() {
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
@@ -125,6 +137,7 @@ function loadRegistry() {
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)
@@ -143,6 +156,7 @@ function loadRegistry() {
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()
@@ -177,23 +191,22 @@ function parseMissingModules(buildOutput) {
// ── Main ───────────────────────────────────────────────────────────
const args = process.argv.slice(2)
const doSelfHeal = args.includes("--self-heal") || args.includes("-s")
// 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
// 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
// Step 3 — Download Playwright browser binaries for scraper automation
run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers")
// 4. .env file
// Step 4 — Bootstrap .env.local from the example template (won't overwrite existing)
if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── Creating .env.local ──")
copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local"))
@@ -202,60 +215,11 @@ if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── .env.local already exists, skipping ──")
}
// 5. Self-healing build check (--self-heal flag)
if (doSelfHeal) {
console.log("\n── Self-Healing Build Check ──")
const { generateModule } = loadRegistry()
let lastGeneratedCount = -1
let iteration = 0
const MAX_ITERATIONS = 5
while (iteration < MAX_ITERATIONS) {
iteration++
console.log(` Build check pass ${iteration}/${MAX_ITERATIONS}...`)
let buildOutput = ""
try {
buildOutput = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT }).toString()
} catch (e) {
buildOutput = e.stdout?.toString() || e.message || ""
}
const missing = parseMissingModules(buildOutput)
const internalMissing = missing
.filter(m => m.isInternal && m.internalPath)
.filter((m, i, arr) => arr.findIndex(x => x.internalPath === m.internalPath) === i) // dedup
if (internalMissing.length === 0) {
console.log(" ✓ No missing internal modules found!")
break
}
console.log(` Found ${internalMissing.length} missing internal module(s)`)
let generated = 0
for (const mod of internalMissing) {
const result = generateModule(mod.internalPath)
if (result.success) {
console.log(`${result.message}`)
generated++
} else {
console.log(`${result.error}`)
}
}
if (generated === 0 || generated === lastGeneratedCount) {
console.log(" No new modules could be generated. Stopping.")
break
}
lastGeneratedCount = generated
}
}
// 6. Final summary
// 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}`)
})
+45 -64
View File
@@ -1,3 +1,16 @@
// ───────────────────────────────────────────────
// 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, useRef } from "react"
@@ -5,14 +18,16 @@ import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat"
import { JobSelector } from "@/components/ai/job-selector"
import { Bot, ChevronRight } from "lucide-react"
const tips = [
"Ask for cold email templates for a specific job",
"Request objection handling tips",
"Ask for outreach strategies per industry",
"Generate a follow up sequence",
"Get LinkedIn connection message templates",
]
/**
* 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[]>([])
@@ -23,26 +38,35 @@ export default function AIAssistantPage() {
setSelectedJob(job)
}, [])
// ── Facebook lead scraper handler ──
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
setSearching(true)
const keyword = job.keywords[0]
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(`http://localhost:3008/scrape/facebook?force=true&query=${encodeURIComponent(keyword)}`, { method: "POST", signal: controller.signal })
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) {
const leadsText = data.leads.map((lead: any, i: number) =>
`**${i + 1}.** ${lead.author || "Unknown"}\n> ${(lead.content || "").slice(0, 300)}\n> 🔗 ${lead.url || "(no link available)"}`
).join("\n\n")
// 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"
@@ -60,14 +84,11 @@ export default function AIAssistantPage() {
}
}, [])
const handleTipClick = useCallback((tip: string) => {
aiChatRef.current?.fillInput(tip)
}, [])
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)
@@ -77,7 +98,9 @@ export default function AIAssistantPage() {
return (
<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">
@@ -89,6 +112,7 @@ export default function AIAssistantPage() {
<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" />
@@ -103,6 +127,8 @@ export default function AIAssistantPage() {
</div>
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
</div>
{/* ── 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>
<div className="flex items-center gap-2 mb-3">
@@ -110,6 +136,7 @@ export default function AIAssistantPage() {
<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-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>
@@ -123,53 +150,7 @@ export default function AIAssistantPage() {
</div>
)}
</div>
<div className="mt-6">
<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]">Quick Tips</span>
</div>
{tips.map((tip, i) => (
<div
key={i}
onClick={() => handleTipClick(tip)}
className="flex items-start gap-2.5 bg-card/50 hover:bg-card border border-border hover:border-primary/20 rounded-xl p-3.5 mb-2 cursor-pointer transition-all duration-200 group"
>
<ChevronRight className="h-3.5 w-3.5 mt-0.5 text-muted-foreground group-hover:text-primary transition-colors duration-200 flex-shrink-0" />
<span className="text-muted-foreground text-xs leading-5 group-hover:text-foreground transition-colors duration-200">{tip}</span>
</div>
))}
</div>
<div className="mt-6">
<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]">Recent Prompts</span>
</div>
{recentPrompts.length > 0 ? (
recentPrompts.map((prompt, i) => (
<div
key={i}
onClick={() => handleRecentPromptClick(prompt)}
className="bg-card/50 rounded-xl p-3 mb-2 border border-border text-muted-foreground text-xs truncate hover:text-foreground cursor-pointer transition-colors duration-200"
>
{prompt}
</div>
))
) : (
<div className="text-muted-foreground text-xs text-center py-4">Your recent prompts will appear here</div>
)}
</div>
<div className="mt-auto border-t border-border pt-4">
<div className="flex gap-4">
<div className="flex-1">
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Messages today</div>
<div className="text-foreground text-sm font-semibold mt-0.5">24</div>
</div>
<div className="flex-1">
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Tokens used</div>
<div className="text-foreground text-sm font-semibold mt-0.5">12.4k</div>
</div>
</div>
</div>
</div>
</div>
)
+46 -10
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"
@@ -25,16 +38,19 @@ import {
ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react"
// ── Event type icon mapping ──
const EVENT_ICONS: Record<EventType, React.ElementType> = {
call: Phone,
follow_up: Clock,
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 {
@@ -44,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()
}
@@ -67,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()
@@ -286,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}`, {
@@ -295,6 +326,7 @@ export default function CalendarPage() {
})
if (!res.ok) throw new Error("Failed to update")
const data = await res.json()
// 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)
@@ -303,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)
@@ -315,6 +348,7 @@ 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, 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 })
}
@@ -327,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" })
@@ -340,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)
@@ -348,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">
@@ -377,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">
@@ -391,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" />
@@ -412,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(
@@ -428,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" />
@@ -502,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">
@@ -612,7 +648,7 @@ export default function CalendarPage() {
)}
</div>
{/* Create/Edit Dialog */}
{/* ── CREATE / EDIT EVENT DIALOG ── */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
@@ -768,7 +804,7 @@ 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] max-h-[90vh] overflow-y-auto">
+67 -11
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"
@@ -20,6 +34,7 @@ import {
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(() => {
@@ -793,11 +845,12 @@ const formatPreviewContent = (content: string) => {
return (
<div className="flex h-[calc(100dvh-4rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
{/* Conversations list - left panel */}
{/* ── CONVERSATIONS LIST (left panel) ── */}
<div
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)}>
@@ -950,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) => {
@@ -1041,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 ? (
@@ -1104,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">
@@ -1233,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">
@@ -1254,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>
@@ -1268,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 && (
@@ -1281,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>
+31
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"
@@ -26,12 +38,23 @@ import {
import { DashboardStats } from "@/types"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
/**
* DashboardPage
*
* Fetches dashboard stats for a selected period
* and renders stat cards, charts, and a recent-
* leads table. Includes auto-polling every 30 s.
*
* @returns Full dashboard layout with loading
* skeleton states.
*/
export default function DashboardPage() {
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}`)
@@ -44,6 +67,7 @@ export default function DashboardPage() {
}
}
// ── Initial fetch + 30-second polling ──
useEffect(() => {
fetchStats(period)
pollingRef.current = setInterval(() => fetchStats(period), 30000)
@@ -52,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 },
@@ -66,6 +91,7 @@ export default function DashboardPage() {
return (
<div className="space-y-6 relative">
<div className="relative z-[1] space-y-6">
{/* ── Header with period filter ── */}
<div>
<PageHeader
title={<span style={{fontFamily:"'Bangers',cursive",letterSpacing:"0.05em"}}>Dashboard</span>}
@@ -86,6 +112,7 @@ export default function DashboardPage() {
</PageHeader>
</div>
{/* ── 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">
@@ -97,6 +124,7 @@ export default function DashboardPage() {
}
</div>
{/* ── 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">
@@ -104,8 +132,11 @@ export default function DashboardPage() {
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
</div>
{/* ── Recent leads table ── */}
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div>
{/* ── Themed decorative overlay (Spidey theme) ── */}
{websiteTheme === "spidey" && (
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
+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 (
+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>
+31 -9
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 = [
{ 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 },
]
/** 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) {
+14
View File
@@ -1,9 +1,20 @@
// ── 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()
@@ -21,6 +32,7 @@ export async function GET(request: NextRequest) {
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`
@@ -36,6 +48,8 @@ export async function GET(request: NextRequest) {
}
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,
+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 })
}
+18 -6
View File
@@ -1,3 +1,9 @@
// ── 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,
@@ -12,7 +18,8 @@ import {
setSessionContext,
SESSION_COOKIE,
} from "@/lib/auth"
import { query } from "@/lib/db"
// ── Helpers ──────────────────────────────────────────────────────────────────
function jsonResponse(data: unknown, status: number) {
return new Response(JSON.stringify(data), {
@@ -21,12 +28,15 @@ function jsonResponse(data: unknown, status: number) {
})
}
// ── 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 jsonResponse(
{ error: "Email/Username and password are required." },
@@ -34,6 +44,7 @@ export async function POST(request: NextRequest) {
)
}
// Reject empty strings (whitespace-only credentials)
if (credential.trim().length === 0 || password.trim().length === 0) {
return jsonResponse(
{ error: "Credentials cannot be empty." },
@@ -41,6 +52,7 @@ export async function POST(request: NextRequest) {
)
}
// 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") ||
@@ -58,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,
@@ -73,6 +86,7 @@ export async function POST(request: NextRequest) {
)
}
// Check if account is temporarily locked due to too many failed attempts
const lockStatus = await isAccountLocked(dbUser)
if (lockStatus.locked) {
await recordLoginAttempt(
@@ -89,6 +103,7 @@ export async function POST(request: NextRequest) {
)
}
// Verify password hash against stored hash
const valid = await comparePassword(password, dbUser.password_hash)
if (!valid) {
await incrementFailedAttempts(dbUser.id)
@@ -106,6 +121,7 @@ export async function POST(request: NextRequest) {
)
}
// Successful login: reset failure counter, log success, create session
await resetFailedAttempts(dbUser.id)
await recordLoginAttempt(
dbUser.id,
@@ -115,16 +131,12 @@ export async function POST(request: NextRequest) {
true
)
await query(
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
[dbUser.id, JSON.stringify({ website_theme: "default" })],
)
const token = await createSession(dbUser.id, dbUser.role_name)
await setSessionContext(dbUser.id, ipAddress)
const user = mapDbUserToSessionUser(dbUser)
// 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 }), {
+9 -1
View File
@@ -1,12 +1,20 @@
// ── 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 {
// 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`,
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${process.env.NODE_ENV === "production" ? "; Secure" : ""}`,
},
})
} catch (error) {
+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`,
+51 -19
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,18 +31,23 @@ 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
)
ORDER BY c.updated_at DESC
LIMIT 50`,
ORDER BY c.updated_at DESC
LIMIT 50`,
[user.id],
)
@@ -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(
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
)
const conversationId = convResult.rows[0].id
// 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 INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
[conversationId, user.id, userId],
)
// 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(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
FROM users WHERE id = $1`,
[userId],
)
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, rangeOverride?: { start: Date; end: Date }) {
const { start, end } = rangeOverride || 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, yearParam ? { start, end } : undefined)
// ── 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()
+9
View File
@@ -1,7 +1,16 @@
// ── 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()
+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,
+18 -1
View File
@@ -1,8 +1,17 @@
// ── 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()
@@ -11,6 +20,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const { id } = await params
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.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,
@@ -37,6 +47,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
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) ||
@@ -49,6 +60,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
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),
@@ -88,7 +100,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const r = result.rows[0]
// Auto-close lead when developer marks event as completed
// 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) {
@@ -99,6 +111,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
}
// 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
@@ -118,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
@@ -163,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()
@@ -180,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 })
}
+95 -93
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,6 +22,7 @@ export async function GET(request: NextRequest) {
const end = searchParams.get("end")
const status = searchParams.get("status")
// 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,
@@ -38,6 +48,7 @@ export async function GET(request: NextRequest) {
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} OR e.developer_id = $${idx}`
params.push(user.id)
@@ -68,6 +79,7 @@ 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,
@@ -100,6 +112,8 @@ export async function GET(request: NextRequest) {
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -115,91 +129,91 @@ export async function POST(request: NextRequest) {
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, 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,
developerId || null,
leadId || null,
conversationId || null,
title,
description || null,
eventType || "website_creation",
startTime || null,
eventType === "website_creation" ? null : (endTime || null),
eventType === "website_creation" ? null : (durationMinutes || null),
clientName || null,
clientEmail || null,
clientPhone || null,
],
user.id,
)
// 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, 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,
],
)
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],
)
}
}
// Auto-update lead to "pending" when a website creation event is created
if (r.event_type === "website_creation" && leadId) {
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
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, 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) {
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,
)
}
}
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",
],
)
}
// 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
}
}
// Notify developer
if (developerId && developerId !== 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)`,
[
developerId,
"event_scheduled",
`Project assigned: ${title}`,
`${user.firstName} ${user.lastName} assigned a project to you`,
`/calendar`,
r.id,
"scheduled_event",
],
)
}
return { r, usersMap }
}, user.id)
const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId],
) : null
const participant = participantResult?.rows[0] || null
const developerResult = developerId ? await query(
`SELECT id, first_name, last_name, email FROM users WHERE id = $1`,
[developerId],
) : null
const dev = developerResult?.rows[0] || null
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,
@@ -215,28 +229,16 @@ export async function POST(request: NextRequest) {
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,
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,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at,
clientName: r.client_name || null, clientEmail: r.client_email || null, clientPhone: r.client_phone || null,
},
}, { status: 201 })
} catch (error) {
+15
View File
@@ -1,9 +1,20 @@
// ── 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"
// ── 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()
@@ -22,6 +33,7 @@ export async function GET(request: NextRequest) {
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) {
@@ -31,6 +43,7 @@ export async function GET(request: NextRequest) {
}
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) })
@@ -61,6 +74,8 @@ export async function GET(request: NextRequest) {
data = await fallbackRes.json()
}
}
// Normalize response to a minimal set of fields
const results = (data.data || []).map((item: any) => {
const dims = item.images?.original
return {
+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)
+77 -23
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,
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`
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) {
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
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) {
sql += ` AND (l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`
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) {
sql += ` AND l.assigned_to = $${paramIdx}`
conditions.push(`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
// 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 result = await query(sql, params)
const whereClause = conditions.join(" AND ")
let leads = result.rows.map((r: any) => {
// 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 ${whereClause}
ORDER BY l.created_at DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
const dataParams = [...params, limit, offset]
const result = await query(dataSql, dataParams)
// 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)
@@ -1,7 +1,15 @@
// ── Settings: Facebook Scrape Logs ──────────────────────────────────────────
// GET /api/settings/facebook/logs?accountId=&limit=&offset=
// — List Facebook scraping session logs, optionally filtered by 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(request: NextRequest) {
try {
const user = await getSessionUser()
+12
View File
@@ -1,7 +1,16 @@
// ── Settings: User Preferences ───────────────────────────────────────────────
// GET /api/settings/preferences — Get the current user's preferences
// PATCH /api/settings/preferences — Upsert user preferences
//
// Auth: authenticated
// Preferences: timezone, dateFormat, itemsPerPage
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()
@@ -32,6 +41,8 @@ export async function GET() {
}
}
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -39,6 +50,7 @@ export async function PATCH(request: NextRequest) {
const body = await request.json()
// Upsert: create if not exists, update if it does
await query(
`INSERT INTO user_preferences (user_id, timezone, date_format, items_per_page, updated_at)
VALUES ($1, $2, $3, $4, NOW())
+40 -7
View File
@@ -1,39 +1,72 @@
// ── Settings: Website Theme ──────────────────────────────────────────────────
// GET /api/settings/website-theme — Get the current user's theme preference
// PUT /api/settings/website-theme — Update theme preference (stored in JSONB)
//
// Auth: authenticated
// Reads and writes the website_theme and color_theme keys inside the user's
// preferences JSONB column, preserving other preference data.
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()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Extract website_theme and color_theme from the JSONB preferences column
const result = await query(
`SELECT preferences->>'website_theme' AS website_theme FROM users WHERE id = $1`,
`SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_theme FROM users WHERE id = $1`,
[user.id],
)
const websiteTheme = result.rows[0]?.website_theme || "default"
return NextResponse.json({ websiteTheme })
const colorTheme = result.rows[0]?.color_theme || null
return NextResponse.json({ websiteTheme, colorTheme })
} catch (error) {
console.error("Website theme GET error:", error)
return NextResponse.json({ error: "Failed to load website theme" }, { status: 500 })
}
}
// ── PUT ──────────────────────────────────────────────────────────────────────
export async function PUT(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const theme = body.websiteTheme || "default"
const update: Record<string, string> = {}
if (body.websiteTheme !== undefined) {
update.website_theme = body.websiteTheme || "default"
}
if (body.colorTheme !== undefined) {
update.color_theme = body.colorTheme || "default"
}
await query(
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
[user.id, JSON.stringify({ website_theme: theme })],
// Merge into the existing JSONB preferences using || operator
if (Object.keys(update).length > 0) {
await query(
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
[user.id, JSON.stringify(update)],
)
}
// Return the updated values
const result = await query(
`SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_theme FROM users WHERE id = $1`,
[user.id],
)
return NextResponse.json({ success: true, websiteTheme: theme })
return NextResponse.json({
success: true,
websiteTheme: result.rows[0]?.website_theme || "default",
colorTheme: result.rows[0]?.color_theme || null,
})
} catch (error) {
console.error("Website theme PUT error:", error)
return NextResponse.json({ error: "Failed to save website theme" }, { status: 500 })
+11 -1
View File
@@ -1,10 +1,20 @@
// ── System: Monitor ──────────────────────────────────────────────────────────
// GET /api/system/monitor — Return basic system health metrics
//
// Auth: authenticated
// Returns: RSS memory (MB), CPU usage (% of one core), CPU core count
// CPU is calculated as process CPU time delta over wall clock time.
import { NextResponse } from "next/server"
import os from "os"
import { getSessionUser } from "@/lib/auth"
// Module-level state for CPU delta calculation
let prevCpu = process.cpuUsage()
let prevTime = Date.now()
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
@@ -17,7 +27,7 @@ export async function GET() {
const sys = currentCpu.system - prevCpu.system
const totalUs = user + sys
// CPU time (ms) / wall time (ms) * 100 = % of one core
// CPU time (microseconds) / wall time (milliseconds * 1000) * 100 = % of one core
const cpuPct = elapsed > 0 ? Math.round((totalUs / 1000) / elapsed * 100 * 10) / 10 : 0
prevCpu = currentCpu
+11
View File
@@ -1,9 +1,19 @@
// ── Upload: Voice Message ────────────────────────────────────────────────────
// POST /api/upload/voice — Upload a voice note audio file
//
// Auth: authenticated
// Accepts multipart/form-data with an "audio" file field and "duration" field.
// Saves the file to public/uploads/voice/ with a UUID filename.
// Returns the public URL path and duration for use in chat messages.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { writeFile } from "node:fs/promises"
import { join } from "node:path"
import crypto from "node:crypto"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -15,6 +25,7 @@ export async function POST(request: NextRequest) {
const duration = parseFloat(formData.get("duration")?.toString() || "0")
// Always use .webm extension (normalize from whatever the client sends)
const ext = file.name.endsWith(".webm") ? "webm" : "webm"
const filename = `${crypto.randomUUID()}.${ext}`
const buffer = Buffer.from(await file.arrayBuffer())
+9
View File
@@ -1,7 +1,14 @@
// ── Users: Single User ───────────────────────────────────────────────────────
// DELETE /api/users/[id] — Soft-delete a user account
//
// Auth: super_admin only; cannot delete yourself
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser } from "@/lib/auth"
// ── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const sessionUser = await getSessionUser()
@@ -14,10 +21,12 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
const { id } = await params
// Prevent self-deletion to avoid admin lockout
if (id === sessionUser.id) {
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 })
}
// Soft-delete: set deleted_at rather than removing the row
await query(
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
[id]
+13 -1
View File
@@ -1,10 +1,20 @@
// ── Users: Avatar Upload ────────────────────────────────────────────────────
// POST /api/users/avatar — Update the current user's avatar (base64 data URL)
//
// Auth: authenticated
// Accepts PNG, JPEG, GIF data URIs up to 2MB decoded size.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── Constants ────────────────────────────────────────────────────────────────
const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"]
const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -15,18 +25,20 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
}
// Validate image format by checking the data URL prefix
const allowed = ALLOWED_PREFIXES.some((p) => avatar.startsWith(p))
if (!allowed) {
return NextResponse.json({ error: "Avatar must be a PNG, JPEG, or GIF data URL" }, { status: 400 })
}
// Approximate decoded size: base64 is ~4/3 of original
// Approximate decoded size: base64 encodes ~4 bytes as 3 bytes of data
const base64Data = avatar.split(",")[1] || ""
const estimatedBytes = Math.round(base64Data.length * 0.75)
if (estimatedBytes > MAX_AVATAR_BYTES) {
return NextResponse.json({ error: "Avatar exceeds 2MB size limit" }, { status: 400 })
}
// Store the full data URL in the avatar_url column
await query(
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
[avatar, user.id],
+18
View File
@@ -1,7 +1,25 @@
// ── Users: Phone Lookup ──────────────────────────────────────────────────────
// GET /api/users/lookup?phone= — Look up a user by phone number
//
// Auth: admin/super_admin only
// Returns found: true + user data or found: false if no match.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
if (sessionUser.role !== "super_admin" && sessionUser.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const phone = request.nextUrl.searchParams.get("phone")
if (!phone) {
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
+15
View File
@@ -1,8 +1,16 @@
// ── Users: Collection ────────────────────────────────────────────────────────
// GET /api/users — List users (paginated); admin/super_admin only
// POST /api/users — Create a new user; super_admin only
//
// Auth: GET requires admin/super_admin; POST requires super_admin
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
import { avatarSvgUrl } from "@/lib/avatar"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
@@ -15,6 +23,7 @@ export async function GET(request: NextRequest) {
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
// Fetch users with their role name, excluding soft-deleted records
const result = await query(
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
u.is_active AS active, u.created_at, u.avatar_url,
@@ -43,6 +52,8 @@ export async function GET(request: NextRequest) {
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
@@ -63,6 +74,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
}
// Derive first/last name from full name string
const nameParts = name.trim().split(/\s+/)
const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(" ") || firstName
@@ -72,6 +84,7 @@ export async function POST(request: NextRequest) {
await setSessionContext(sessionUser.id)
// Insert user record with both hash (for login) and encrypted (for recovery)
const result = await query(
`INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
@@ -79,6 +92,7 @@ export async function POST(request: NextRequest) {
[username.toLowerCase(), email.toLowerCase(), passwordHash, passwordEncrypted, firstName, lastName, active ?? true, sessionUser.id]
)
// Assign the requested role via the user_roles junction table
const roleId = (
await query(`SELECT id FROM roles WHERE LOWER(name) = LOWER($1)`, [role])
).rows[0]?.id
@@ -94,6 +108,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error: any) {
console.error("Error creating user:", error)
// Handle unique constraint violations for username or email
if (error?.constraint === "uq_users_username" || error?.constraint === "uq_users_email") {
return NextResponse.json({ error: "A user with this email or username already exists" }, { status: 409 })
}
+9
View File
@@ -1,8 +1,16 @@
// ── Users: Search ────────────────────────────────────────────────────────────
// GET /api/users/search?q= — Typeahead search users by name or email
//
// Auth: admin/super_admin only
// Returns up to 10 matching users, excluding the requester themselves.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const currentUser = await getSessionUser()
@@ -17,6 +25,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ users: [] })
}
// Case-insensitive search on full name and email; exclude current user
const result = await query(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
FROM users
+41
View File
@@ -1,9 +1,33 @@
// ───────────────────────────────────────────────
// Call Room Page (/call/[roomId])
// ───────────────────────────────────────────────
// Route: /call/:roomId
// Purpose: Anonymous WebRTC voice-call room.
// Handles pre-join (name entry, mic permission),
// connection states (calling, waiting, connecting,
// connected), and post-call (ended) states.
// Layout: Centered card with state-dependent UI
// — join form, call controls, or end screen.
// Uses: useWebRTCCall hook with anonymous mode.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect, use, useCallback } from "react"
import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, Users, Loader2 } from "lucide-react"
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
/**
* CallRoomPage
*
* Manages the full lifecycle of an anonymous voice
* call in a room. Pre-join: requests mic, shows
* name input. Joined: renders call state UI.
* Auto-detects connection timeouts (10 s).
*
* @param params - Route params containing roomId.
* @returns Call room UI for each state.
*/
export default function CallRoomPage({ params }: { params: Promise<{ roomId: string }> }) {
const { roomId } = use(params)
@@ -27,6 +51,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
const [micError, setMicError] = useState<string | null>(null)
const [connectionTimeout, setConnectionTimeout] = useState(false)
// ── 10-second connection timeout detection ──
useEffect(() => {
if (isReady) return
const timer = setTimeout(() => {
@@ -35,6 +60,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
return () => clearTimeout(timer)
}, [isReady])
// ── Join handler: requests mic, then joins room ──
const handleJoin = useCallback(async () => {
if (!displayName.trim()) return
setMicError(null)
@@ -51,15 +77,18 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
endCall()
}, [endCall])
// ── Pre-join UI (not yet in the room) ──
if (!joined) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
{connectionTimeout ? (
// Socket connection failed after 10 s
<>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">This call is no longer available.</h1>
</>
) : !isReady ? (
// Still connecting to signaling server
<>
<div className="flex flex-col items-center gap-4 py-6">
<Loader2 className="h-8 w-8 animate-spin text-[#C84B4B]" />
@@ -67,6 +96,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
</>
) : (
// Name entry form
<>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">Join Call</h1>
{micError && (
@@ -95,9 +125,11 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
)
}
// ── In-call UI (joined) ──
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
{/* ── Calling state ── */}
{callState === "calling" && (
<div>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Calling</h1>
@@ -110,6 +142,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
)}
{/* ── Waiting for other participant ── */}
{callState === "waiting" && (
<div>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Waiting for participant</h1>
@@ -133,6 +166,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
)}
{/* ── Participant joined — transitioning to connect ── */}
{callState === "participant_joined" && (
<div>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Participant joined</h1>
@@ -156,6 +190,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
)}
{/* ── Establishing WebRTC connection ── */}
{callState === "connecting" && (
<div>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Connecting</h1>
@@ -168,6 +203,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
)}
{/* ── Active call with controls ── */}
{callState === "connected" && (
<div>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-1">Connected</h1>
@@ -186,18 +222,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
{formatDuration(callDuration)}
</div>
<div className="flex justify-center gap-4">
{/* Speaker toggle */}
<button
onClick={toggleSpeaker}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#5A8FC4] dark:bg-[#1144FF]' : 'bg-[#8A9078] dark:bg-[#333333]'}`}
>
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
</button>
{/* Mute toggle */}
<button
onClick={toggleMute}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#5A8FC4] dark:bg-[#1144FF]' : 'bg-[#8A9078] dark:bg-[#333333]'}`}
>
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
</button>
{/* End call */}
<button
onClick={handleEnd}
className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
@@ -208,6 +247,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
)}
{/* ── Call ended / idle ── */}
{(callState === "ended" || callState === "idle") && (
<div>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Call ended</h1>
@@ -217,6 +257,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
)}
{/* ── Error banner ── */}
{error && (
<p className="text-[#C84B4B] text-xs mt-4">{error}</p>
)}
+41
View File
@@ -1,3 +1,18 @@
// ───────────────────────────────────────────────
// Join (Invite) Page (/join/[token])
// ───────────────────────────────────────────────
// Route: /join/:token
// Purpose: Server component that validates an
// invite token. If the token is a UUID (room
// ID), it redirects directly to /call/:token.
// Otherwise it queries the DB for an SMS-style
// invite and shows a "You're Invited!" landing
// page with the caller's phone number.
// Layout: Centered card with invite details and
// a "Create Account" CTA.
// Error states: invalid token, expired token.
// ───────────────────────────────────────────────
import { query } from "@/lib/db"
import { redirect } from "next/navigation"
@@ -5,6 +20,16 @@ interface Props {
params: Promise<{ token: string }>
}
/**
* ErrorPage
*
* Shared inline component for rendering error
* states (invalid / expired) with the same
* card styling.
*
* @param message - The error message to display.
* @returns A centered error card.
*/
function ErrorPage({ message }: { message: string }) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
@@ -17,15 +42,28 @@ function ErrorPage({ message }: { message: string }) {
)
}
/**
* JoinPage
*
* Validates an invite token. UUID tokens redirect
* directly into a call room. DB-backed tokens
* (from SMS invites) show a landing page with
* the inviter's contact info and a register CTA.
*
* @param params - Route params containing the token.
* @returns An error page, redirect, or invite page.
*/
export default async function JoinPage({ params }: Props) {
const { token } = await params
// ── UUID tokens → direct call room redirect ──
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (UUID_REGEX.test(token)) {
redirect(`/call/${token}`)
}
// ── Look up SMS invite token in DB ──
const result = await query(
`SELECT phone, created_at, expires_at FROM invites WHERE token = $1 LIMIT 1`,
[token],
@@ -37,6 +75,7 @@ export default async function JoinPage({ params }: Props) {
const invite = result.rows[0]
// ── Check expiration ──
if (new Date(invite.expires_at) <= new Date()) {
return <ErrorPage message="This call has expired." />
}
@@ -50,6 +89,7 @@ export default async function JoinPage({ params }: Props) {
<p className="text-[#8A9078] text-sm mb-6">
Someone wants to connect with you on our platform.
</p>
{/* ── Inviter's contact number ── */}
<div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
<p className="text-xs text-[#8A9078] mb-1">Contact Number</p>
<p className="text-sm font-medium text-[#2D3020] dark:text-white">{invite.phone}</p>
@@ -57,6 +97,7 @@ export default async function JoinPage({ params }: Props) {
<p className="text-xs text-[#8A9078] mb-6">
Create an account to start making free voice calls to this person and others on the platform.
</p>
{/* ── Registration CTA ── */}
<a
href="/register"
className="block w-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
+5
View File
@@ -33,6 +33,11 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} min-h-screen antialiased`}>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("crm-color-theme");if(t&&t!=="default")document.documentElement.classList.add(t);var w=localStorage.getItem("crm-website-theme");if(w==="spidey")document.documentElement.classList.add("theme-spidey")}catch(e){}})()`,
}}
/>
<ThemeProvider attribute="class" defaultTheme="light" disableTransitionOnChange>
<WebsiteThemeProvider>
{children}
+69
View File
@@ -1,11 +1,28 @@
// ───────────────────────────────────────────────
// Login Page (/login)
// ───────────────────────────────────────────────
// Route: /login
// Purpose: Client-side login page with animated
// background (canvas waves + stars), a hero
// testimonial carousel, live stat counters, and
// a credential form that hits /api/auth/login.
// Layout: Two-panel split — left = branding +
// stats, right = login form.
//
// NOTE: Wrapped in <Suspense> because LoginForm
// calls useSearchParams().
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect, useRef, Suspense } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { Eye, EyeOff, Loader2 } from "lucide-react"
/** LocalStorage key used to persist the user's chosen website theme. */
const WEBSITE_THEME_KEY = "crm-website-theme"
/** Configuration for the animated sine-wave canvas (amplitude, frequency, speed, vertical offset, fill). */
const waves = [
{ a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" },
{ a: 20, f: 0.008, s: 0.013, y: 0.52, fill: "rgba(27,176,206,0.25)" },
@@ -14,6 +31,16 @@ const waves = [
{ a: 10, f: 0.019, s: 0.030, y: 0.20, fill: "rgba(27,176,206,0.10)" },
]
/**
* LoginPage
*
* Wraps LoginForm in Suspense because it uses
* useSearchParams (requires a client boundary
* with fallback UI).
*
* @returns A spinner fallback while the form loads,
* then the LoginForm component.
*/
export default function LoginPage() {
return (
<Suspense fallback={
@@ -26,6 +53,17 @@ export default function LoginPage() {
)
}
/**
* LoginForm
*
* Core login UI. Manages form state, animated
* canvas background (waves + stars), rotating
* testimonial carousel, and animated stat
* counters. On submit, POSTs to /api/auth/login
* and redirects on success.
*
* @returns The full login-page JSX layout.
*/
function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
@@ -39,6 +77,7 @@ function LoginForm() {
const counterStarted = useRef(false)
const [testimonialIndex, setTestimonialIndex] = useState(0)
/** Rotating testimonials shown in the left panel. */
const testimonials = [
{
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
@@ -50,15 +89,19 @@ function LoginForm() {
},
]
// ── Rotate testimonials every 5 seconds ──
useEffect(() => {
const timer = setInterval(() => {
setTestimonialIndex((prev) => (prev + 1) % testimonials.length)
}, 5000)
return () => clearInterval(timer)
}, [testimonials.length])
/** Ref for the sine-wave canvas (left panel). */
const canvasRef = useRef<HTMLCanvasElement>(null)
/** Ref for the star-field canvas (right panel). */
const starsCanvasRightRef = useRef<HTMLCanvasElement>(null)
// ── Animated sine-wave canvas (left panel background) ──
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
@@ -68,6 +111,7 @@ function LoginForm() {
let animId: number
let time = 0
/** Handle HiDPI (Retina) display scaling. */
const resize = () => {
const parent = canvas.parentElement!
const rect = parent.getBoundingClientRect()
@@ -82,6 +126,7 @@ function LoginForm() {
resize()
window.addEventListener("resize", resize)
/** Render each wave layer using a sine function. */
const draw = () => {
const w = canvas.width / (window.devicePixelRatio || 1)
const h = 200
@@ -112,10 +157,12 @@ function LoginForm() {
}
}, [])
// ── Twinkling star-field canvas (right panel background) ──
useEffect(() => {
const canvases = [starsCanvasRightRef.current].filter(Boolean) as HTMLCanvasElement[]
if (canvases.length === 0) return
/** Generate 312 stars with random positions, sizes, opacities, and drift velocities. */
const stars = Array.from({ length: 312 }, () => ({
x: Math.random(),
y: Math.random(),
@@ -130,6 +177,7 @@ function LoginForm() {
let animId: number
let time = 0
/** Handle HiDPI resize for all star canvases. */
const resize = () => {
for (const c of canvases) {
const dpr = window.devicePixelRatio || 1
@@ -149,6 +197,7 @@ function LoginForm() {
})
window.addEventListener("resize", resize)
/** Render each star with a sinusoidal opacity twinkle and slow parallax drift. */
const draw = () => {
time += 1
for (const c of canvases) {
@@ -169,6 +218,7 @@ function LoginForm() {
ctx.fillStyle = `rgba(255,255,255,${opacity})`
ctx.fill()
// Brighter stars get a subtle glow halo
if (star.r > 0.5) {
ctx.beginPath()
ctx.arc(x, y, star.r * 1.8, 0, Math.PI * 2)
@@ -190,12 +240,14 @@ function LoginForm() {
}
}, [])
// ── Animated stat counters (left panel) ──
useEffect(() => {
if (counterStarted.current) return
counterStarted.current = true
const targets = { leads: 1247, conversion: 40, users: 83 }
const steps = 150
// Delayed start so the user sees the page first
const delayTimer = setTimeout(() => {
let step = 0
const interval = setInterval(() => {
@@ -205,6 +257,7 @@ function LoginForm() {
setCounts(targets)
return
}
// Increment each counter proportionally toward its target
setCounts({
leads: Math.min(Math.floor((targets.leads / steps) * step), targets.leads),
conversion: Math.min(Math.floor((targets.conversion / steps) * step), targets.conversion),
@@ -216,6 +269,7 @@ function LoginForm() {
return () => clearTimeout(delayTimer)
}, [])
// ── Form submission handler ──
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
@@ -229,9 +283,11 @@ function LoginForm() {
})
if (res.ok) {
// Clear any persisted theme so dashboard picks up the user's saved preference
localStorage.removeItem(WEBSITE_THEME_KEY)
const root = document.documentElement
root.className = root.className.split(" ").filter((c) => !c.startsWith("theme-")).join(" ").trim()
// Respect ?redirect= query param (set by middleware for unauthenticated access)
const redirectTo = searchParams.get("redirect") || "/dashboard"
router.push(redirectTo)
} else {
@@ -246,7 +302,9 @@ function LoginForm() {
}
return (
// ── Full-page two-panel layout ──
<div className="flex min-h-screen bg-[#0a0a0f]">
{/* ── LEFT PANEL: Branding, stats, testimonials ── */}
<div className="left-panel flex flex-1 flex-col p-12">
<div className="relative z-10 flex-1 flex items-center justify-center">
<div className="text-center">
@@ -261,6 +319,7 @@ function LoginForm() {
<p className="mt-4 text-[13px] leading-[1.7] max-w-[360px] mx-auto body-text">
Manage leads, track conversions, and grow your web development business with our CRM.
</p>
{/* Animated stat counters */}
<div className="stats-row">
<div className="stat">
<div className="stat-number">{counts.leads.toLocaleString()}</div>
@@ -278,6 +337,7 @@ function LoginForm() {
</div>
</div>
{/* ── Testimonial carousel ── */}
<div className="relative z-10 testimonial" style={{ background: "rgba(255,255,255,0.7)", padding: "16px 20px 16px 16px", clipPath: "url(#testimonialClip)" }}>
<svg width="0" height="0" style={{ position: "absolute" }}>
<defs>
@@ -286,6 +346,7 @@ function LoginForm() {
</clipPath>
</defs>
</svg>
{/* Key changes on testimonialIndex to trigger transition */}
<div className="transition-opacity duration-500" key={testimonialIndex}>
<p className="text-sm font-semibold leading-relaxed" style={{ color: "#0a0a0f" }}>
&ldquo;{testimonials[testimonialIndex].text}&rdquo;
@@ -294,6 +355,7 @@ function LoginForm() {
{testimonials[testimonialIndex].author}
</p>
</div>
{/* Dot indicators for manual testimonial selection */}
<div className="flex items-center justify-center gap-1.5 mt-3">
{testimonials.map((_, i) => (
<button
@@ -311,6 +373,7 @@ function LoginForm() {
<div className="panel-divider" />
{/* ── RIGHT PANEL: Login form ── */}
<div className="right-panel flex items-center justify-center p-10">
<canvas ref={starsCanvasRightRef} className="stars-canvas" />
<div className="accent-line" />
@@ -323,6 +386,7 @@ function LoginForm() {
Sign in to your account to continue
</p>
{/* Error alert banner */}
{error && (
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
{error}
@@ -330,6 +394,7 @@ function LoginForm() {
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Email field */}
<div>
<label htmlFor="email" className="login-label">
Email
@@ -348,6 +413,7 @@ function LoginForm() {
</div>
</div>
{/* Password field with show/hide toggle and forgot link */}
<div>
<div className="flex items-center justify-between mb-[6px]">
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
@@ -384,6 +450,7 @@ function LoginForm() {
</div>
</div>
{/* Remember me checkbox */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@@ -396,12 +463,14 @@ function LoginForm() {
</span>
</label>
{/* Submit button with loading state */}
<button type="submit" className="auth-btn" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
{/* Legal footer links */}
<p className="text-center text-[11px] mt-4 footer-text">
By signing in, you agree to our{" "}
<button type="button" className="text-[#1BB0CE] hover:underline">
+20
View File
@@ -1,5 +1,25 @@
// ───────────────────────────────────────────────
// Root Page (/)
// ───────────────────────────────────────────────
// Route: /
// Purpose: Top-level entry point for the app.
// Immediately redirects authenticated users to
// /dashboard. Unauthenticated users are caught
// by middleware and redirected to /login.
// Layout: No layout — pure redirect.
// ───────────────────────────────────────────────
import { redirect } from "next/navigation"
/**
* RootPage
*
* The default route handler for `/`. Immediately
* redirects to the dashboard. Auth guarding is
* handled by src/middleware.ts.
*
* @returns Never always calls redirect().
*/
export default function RootPage() {
redirect("/dashboard")
}
+54 -5
View File
@@ -1,9 +1,17 @@
// ── AI Assistant: Main Chat Component ──
// Full conversational AI chat interface with message history, GIF support,
// server-health polling, localStorage persistence, and an animated boot sequence.
"use client"
import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
import { Bot, Terminal } from "lucide-react"
import { GifPicker } from "./gif-picker"
/**
* Converts URLs in text into clickable anchor elements.
* Splits text by URL regex and renders each part as either a link or plain text fragment.
*/
function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
const parts = text.split(urlRegex)
@@ -15,6 +23,10 @@ function linkifyText(text: string) {
})
}
/**
* Renders AI response text with support for bullet lists ( or -) and paragraphs.
* Each line is transformed: bullets get a colored dot prefix, empty lines become spacing.
*/
function formatContent(text: string) {
const lines = text.split("\n")
return lines.map((line, i) => {
@@ -33,9 +45,11 @@ function formatContent(text: string) {
})
}
/** A single chat message, either from the user or the AI assistant */
interface ChatMessage {
role: "user" | "assistant"
content: string
ts?: number
gif?: {
url: string
previewUrl: string
@@ -44,16 +58,24 @@ interface ChatMessage {
}
interface AIChatProps {
/** Called when the user sends a message */
onMessageSent?: (msg: string) => void
}
/** Exposed imperative methods for parent components */
export interface AIChatHandle {
fillInput: (text: string) => void
addAssistantMessage: (content: string) => void
}
/**
* AIChat the main conversational AI assistant interface.
* Features: boot animation, message history with localStorage, GIF picker,
* markdown-like formatting, and automatic scroll-to-bottom.
*/
export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent }, ref) => {
// ── State & refs ──
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
@@ -66,6 +88,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
const hasUserMessage = messages.some(m => m.role === "user")
const loadedFromStorage = useRef(false)
// ── Expose imperative methods to parent via ref ──
useImperativeHandle(ref, () => ({
fillInput(text: string) {
setInput(text)
@@ -84,11 +107,13 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
},
}), [])
// ── Handle GIF selection from the GIF picker ──
const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => {
setMessages((prev) => [...prev, { role: "user", content: gif.title || "Sent a GIF", gif }])
setShowGifPicker(false)
}, [])
// ── Close GIF picker on outside click ──
useEffect(() => {
if (showGifPicker) {
const handler = (e: MouseEvent) => {
@@ -101,14 +126,21 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [showGifPicker])
// ── Load messages from localStorage, filtering out messages older than 1 week ──
const _WEEK_MS = 604800000
useEffect(() => {
const saved = localStorage.getItem("ai-chat-messages")
if (saved) {
try {
const parsed = JSON.parse(saved)
let parsed = JSON.parse(saved)
if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed)
loadedFromStorage.current = true
const cutoff = Date.now() - _WEEK_MS
parsed = parsed.filter((m: any) => !m.ts || m.ts > cutoff)
if (parsed.length > 0) {
setMessages(parsed)
loadedFromStorage.current = true
}
}
} catch {}
} else {
@@ -116,12 +148,15 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [])
// ── Persist messages to localStorage whenever they change ──
useEffect(() => {
if (messages.length > 0) {
localStorage.setItem("ai-chat-messages", JSON.stringify(messages))
const now = Date.now()
localStorage.setItem("ai-chat-messages", JSON.stringify(messages.map(m => ({ ...m, ts: m.ts || now }))))
}
}, [messages])
// ── Poll the AI server until it is no longer returning 503 (booting) ──
const checkServer = useCallback(async () => {
try {
const res = await fetch("/api/ai/chat", {
@@ -139,7 +174,9 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [])
// ── On mount: poll server, load jobs to build welcome message ──
useEffect(() => {
checkServer()
if (loadedFromStorage.current) return
fetch("/api/ai/jobs")
.then((r) => r.json())
@@ -169,13 +206,14 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
},
])
})
checkServer()
}, [checkServer])
// ── Auto-scroll to bottom when new messages arrive ──
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
// ── Send a message to the AI API ──
const sendMessage = useCallback(async (text?: string) => {
const msg = (text || input).trim()
if (!msg || loading) return
@@ -212,6 +250,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [input, loading, onMessageSent])
// ── Enter key submits; Shift+Enter inserts newline ──
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
@@ -219,12 +258,14 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [sendMessage])
// ── Auto-resize textarea on input ──
const handleTextareaInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
const t = e.target as HTMLTextAreaElement
t.style.height = "auto"
t.style.height = t.scrollHeight + "px"
}, [])
// ── Boot screen ──
if (bootState === "booting") {
return (
<div className="flex items-center justify-center h-full">
@@ -251,6 +292,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
)
}
// ── Welcome screen (ready, no user messages yet) ──
if (bootState === "ready" && !hasUserMessage) {
return (
<div className="flex-1 flex flex-col overflow-y-auto" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
@@ -265,6 +307,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
</div>
</div>
</div>
{/* ── Input area (sticky bottom) ── */}
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
<div className="max-w-3xl mx-auto">
<div className="bg-card rounded-2xl border border-border focus-within:border-primary/40 focus-within:shadow-[0_0_20px_hsl(var(--primary)_/_0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3 relative" ref={containerRef}>
@@ -299,13 +342,16 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
)
}
// ── Chat view (messages present) ──
return (
<div className="flex-1 flex flex-col min-h-0">
{/* ── Message list ── */}
<div className="flex-1 overflow-y-auto px-4 py-6" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
<div className="max-w-3xl mx-auto space-y-6">
{messages.map((msg, i) => (
<div key={i}>
{msg.role === "assistant" ? (
/* ── AI assistant bubble (left-aligned) ── */
<div className="flex gap-3 items-start">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white text-sm flex-shrink-0">
<Bot className="h-4 w-4" />
@@ -318,6 +364,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
</div>
</div>
) : (
/* ── User bubble (right-aligned) ── */
<div className="flex gap-3 items-start flex-row-reverse">
<div className="flex-1 min-w-0 flex justify-end">
<div className="bg-gradient-to-br from-primary to-primary rounded-2xl rounded-tr-sm px-5 py-4 max-w-[75%]">
@@ -337,6 +384,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
)}
</div>
))}
{/* ── Typing indicator (bouncing dots) ── */}
{loading && (
<div className="flex gap-3 items-start">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white text-sm flex-shrink-0">
@@ -352,6 +400,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
<div ref={messagesEndRef} />
</div>
</div>
{/* ── Input area (sticky bottom) ── */}
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
<div className="max-w-3xl mx-auto">
{error && (
+23
View File
@@ -1,8 +1,13 @@
// ── AI Assistant: GIF Picker Component ──
// Inline GIF search and selection UI backed by GIPHY.
// Supports trending, search with debounce, infinite scroll, and client-side caching.
"use client"
import { useState, useRef, useEffect, useCallback } from "react"
import { Search, Loader2, ImageIcon } from "lucide-react"
/** A single GIF result from the GIPHY API */
interface GifResult {
id: string
title: string
@@ -14,12 +19,19 @@ interface GifResult {
}
interface GifPickerProps {
/** Called when a GIF is selected */
onSelect: (gif: { url: string; previewUrl: string; title: string }) => void
/** Called to close the picker */
onClose: () => void
}
/** In-memory cache keyed by search query or "__trending__" */
const SEARCH_CACHE = new Map<string, GifResult[]>()
/**
* GifPicker inline GIF search/selection popover.
* Fetches trending GIFs on open, supports debounced search and infinite scroll via IntersectionObserver.
*/
export function GifPicker({ onSelect, onClose }: GifPickerProps) {
const [gifs, setGifs] = useState<GifResult[]>([])
const [loading, setLoading] = useState(true)
@@ -32,6 +44,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
const inputRef = useRef<HTMLInputElement>(null)
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
// ── Fetch GIFs from the API, with optional cache hit ──
const fetchGifs = useCallback(async (q: string, off: number, append: boolean) => {
if (off === 0) {
const cacheKey = q || "__trending__"
@@ -45,6 +58,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
}
}
// ── Perform the API request ──
try {
if (!append) setLoading(true)
else setLoadingMore(true)
@@ -67,6 +81,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
const data = await res.json()
const newGifs: GifResult[] = data.gifs || []
// Append or replace results, caching on first page
if (append) {
setGifs((prev) => [...prev, ...newGifs])
} else {
@@ -89,14 +104,17 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
}
}, [])
// ── Load trending GIFs on mount ──
useEffect(() => {
fetchGifs("", 0, false)
}, [fetchGifs])
// ── Auto-focus the search input ──
useEffect(() => {
inputRef.current?.focus()
}, [])
// ── Debounced search: 400ms after the user stops typing ──
useEffect(() => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
if (!search.trim()) {
@@ -111,6 +129,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
}
}, [search, fetchGifs])
// ── Infinite scroll via IntersectionObserver on the sentinel ──
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
@@ -129,7 +148,9 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
}, [hasMore, loadingMore, loading, offset, search, fetchGifs])
return (
// ── Popover container ──
<div className="absolute bottom-full left-0 right-0 mb-2 bg-card border border-border rounded-2xl shadow-xl shadow-black/20 overflow-hidden z-50">
{/* ── Search bar ── */}
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -144,6 +165,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
</div>
</div>
{/* ── GIF grid with loading / error / empty states ── */}
<div className="overflow-y-auto" style={{ maxHeight: "360px" }}>
{loading ? (
<div className="flex items-center justify-center py-16">
@@ -184,6 +206,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
)}
{/* Sentinel element for infinite scroll detection */}
{hasMore && <div ref={sentinelRef} className="h-4" />}
</>
)}
+35 -6
View File
@@ -1,8 +1,13 @@
// ── AI Assistant: Job Selector Component ──
// Dropdown that fetches and displays job categories from the API.
// Allows the user to pick a job, then triggers a Facebook search.
"use client"
import { useState, useEffect } from "react"
import { Briefcase, ChevronDown, Loader2, Search } from "lucide-react"
/** A job category returned from the API */
interface Job {
job_title: string
keywords: string[]
@@ -11,17 +16,25 @@ interface Job {
}
interface JobSelectorProps {
/** Called when a job is selected or cleared (null) */
onSelect: (job: Job | null) => void
/** Called when the user clicks the "Search Facebook" button */
onSearch?: (job: Job) => void
/** Whether a search is currently in progress */
searching?: boolean
}
/**
* JobSelector dropdown to pick a job category and initiate a Facebook search.
* Fetches available jobs on mount and provides a "Search Facebook" CTA once a job is selected.
*/
export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps) {
const [jobs, setJobs] = useState<Job[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState<Job | null>(null)
// ── Fetch available job categories on mount ──
useEffect(() => {
fetch("/api/ai/jobs")
.then((r) => r.json())
@@ -30,6 +43,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
.finally(() => setLoading(false))
}, [])
// ── Select a job, close dropdown, notify parent ──
const handleSelect = (job: Job) => {
setSelected(job)
setOpen(false)
@@ -38,39 +52,54 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
return (
<div className="relative">
{/* ── Dropdown trigger button ── */}
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2.5 bg-[#1a1d2e] border border-[#ffffff0f] hover:border-[#f97316]/30 rounded-xl px-4 py-3 text-sm text-[#9ca3af] hover:text-[#e5e7eb] transition-all duration-200"
className="w-full flex items-center gap-2.5 bg-card/50 border border-border hover:border-primary/20 rounded-xl px-4 py-3 text-sm text-muted-foreground hover:text-foreground transition-all duration-200"
>
<Briefcase className="h-4 w-4 text-[#f97316] flex-none" />
<span className="flex-1 text-left truncate">
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
</span>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-[#f97316]" /> : <ChevronDown className={`h-3.5 w-3.5 text-[#4b5563] transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" /> : <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
</button>
{/* ── Dropdown menu — visible when open ── */}
{open && (
<>
{/* Backdrop to close on click outside */}
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-[#1a1d2e] border border-[#ffffff0f] rounded-xl shadow-xl shadow-black/40 max-h-60 overflow-y-auto">
<div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-card border border-border rounded-xl shadow-xl max-h-60 overflow-y-auto">
{jobs.map((job, i) => (
<button
key={i}
type="button"
onClick={() => handleSelect(job)}
className="w-full text-left px-4 py-3 text-sm text-[#9ca3af] hover:bg-[#1f2437] hover:text-[#e5e7eb] transition-all duration-150 border-b border-[#ffffff08] last:border-0 border-l-2 border-l-transparent hover:border-l-[#f97316]/40"
className="w-full text-left px-4 py-3 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-all duration-150 border-b border-border/20 last:border-b-0 border-l-2 border-l-transparent hover:border-l-primary/40"
>
<div className="font-medium">{job.job_title}</div>
<div className="text-xs text-[#4b5563] mt-0.5">{job.industry} &mdash; {job.description}</div>
<div className="text-xs text-muted-foreground/60 mt-0.5">{job.industry} {job.description}</div>
</button>
))}
{jobs.length === 0 && !loading && (
<div className="px-4 py-4 text-xs text-[#4b5563] text-center">No job categories loaded</div>
<div className="px-4 py-4 text-xs text-muted-foreground/60 text-center">No job categories loaded</div>
)}
</div>
</>
)}
{/* ── "Search Facebook" CTA — shown only when a job is selected ── */}
{selected && onSearch && (
<button
type="button"
onClick={() => onSearch(selected)}
disabled={searching}
className="w-full flex items-center justify-center gap-2 mt-3 bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed text-primary-foreground text-sm font-semibold rounded-xl px-4 py-3 transition-all duration-200 shadow-lg shadow-primary/20"
>
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{searching ? "Searching..." : "Search Facebook"}
</button>
)}
</div>
)
}
+16 -2
View File
@@ -1,3 +1,8 @@
// ── Chats: Media Picker Component ──
// Tabbed popover for selecting emojis, GIFs (via GIPHY), and stickers.
// Emojis use emoji-mart, GIFs have search + trending + infinite scroll + caching,
// stickers support multiple packs with recent items stored in localStorage.
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
@@ -8,6 +13,7 @@ import { Search, Image, Sticker, X, Loader2, TrendingUp } from "lucide-react"
import data from "@emoji-mart/data"
import Picker from "@emoji-mart/react"
import { getStickerPacks, type StickerPack } from "@/data/stickers"
import { sanitizeSvg } from "@/lib/sanitize"
type Tab = "emoji" | "gif" | "sticker"
@@ -18,21 +24,29 @@ interface MediaPickerProps {
theme?: string
}
/** localStorage keys for recent media tracking */
const RECENT_GIFS_KEY = "recent-gifs"
const RECENT_STICKERS_KEY = "recent-stickers"
const MAX_RECENT = 12
/** Retrieve recent items from localStorage */
function getRecent(key: string): string[] {
if (typeof window === "undefined") return []
try { return JSON.parse(localStorage.getItem(key) || "[]") } catch { return [] }
}
/** Add an item to the recent list, capped at MAX_RECENT, deduplicated */
function addRecent(key: string, id: string) {
if (typeof window === "undefined") return
const items = [id, ...getRecent(key).filter((x) => x !== id)].slice(0, MAX_RECENT)
localStorage.setItem(key, JSON.stringify(items))
}
/**
* MediaPicker a popover with three tabs:
* 1) Emoji (emoji-mart picker), 2) GIFs (search + trending), 3) Stickers (multi-pack).
* GIFs use debounced search, caching, pagination, and error/offline handling.
*/
export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, theme }: MediaPickerProps) {
const [tab, setTab] = useState<Tab>("emoji")
const [gifQuery, setGifQuery] = useState("")
@@ -287,7 +301,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square"
>
{sticker.svg ? (
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sticker.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sanitizeSvg(sticker.svg).replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
) : (
<span className="text-5xl">{sticker.emoji}</span>
)}
@@ -303,7 +317,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
if (!s) return null
return (
<button key={s.id} type="button" onClick={() => handleStickerSelect(s)} className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square opacity-70 hover:opacity-100">
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: s.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sanitizeSvg(s.svg).replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
</button>
)
})}
+10
View File
@@ -1,3 +1,7 @@
// ── Chats: Voice Call Modal Component ──
// WebRTC-based voice call UI. Allows searching contacts or dialing a number,
// creates a call room, and shares the join link via WhatsApp or clipboard.
"use client"
import { useState, useEffect, useCallback } from "react"
@@ -17,6 +21,7 @@ interface VoiceCallModalProps {
onClose: () => void
}
/** Converts a phone number to international format suitable for WhatsApp links */
function formatPhoneForWhatsApp(phone: string): string {
const digits = phone.replace(/[^0-9]/g, "")
if (!digits) return ""
@@ -25,6 +30,11 @@ function formatPhoneForWhatsApp(phone: string): string {
return digits
}
/**
* VoiceCallModal modal for initiating a WebRTC voice call.
* Steps: 1) Select contact or dial number, 2) Create room & get link,
* 3) Share link via WhatsApp or copy to clipboard, 4) Open call page.
*/
export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
const [contacts, setContacts] = useState<Contact[]>([])
const [contactsLoading, setContactsLoading] = useState(false)
@@ -1,5 +1,12 @@
// ── Dashboard: Crossed Lightsabers SVG ──
// Animated SVG of two crossed lightsabers (red and blue) with glow effects and pulsing hilts.
"use client"
/**
* CrossedLightsabers decorative SVG with animated red and blue lightsabers crossed.
* Includes a purple glow circle at the intersection point and detailed hilt/button details.
*/
export function CrossedLightsabers() {
return (
<svg
@@ -1,3 +1,8 @@
// ── Dashboard: Lead Status Donut Chart ──
// Interactive SVG donut chart showing lead distribution by status.
// Hover highlights a segment and shows its value/percentage in the center.
// Includes a legend with colored dots and percentages.
"use client"
import { useState } from "react"
@@ -14,11 +19,13 @@ interface LeadStatusChartProps {
data: StatusData[]
}
/** Convert polar coordinates to cartesian */
function polar(cx: number, cy: number, r: number, deg: number) {
const rad = ((deg - 90) * Math.PI) / 180
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
}
/** Generate an SVG arc path for a donut segment with inner and outer radii */
function arcPath(cx: number, cy: number, oR: number, iR: number, start: number, end: number) {
const gap = 3
const s = start + gap / 2
@@ -31,6 +38,10 @@ function arcPath(cx: number, cy: number, oR: number, iR: number, start: number,
return `M${o1.x} ${o1.y} A${oR} ${oR} 0 ${lg} 1 ${o2.x} ${o2.y} L${i1.x} ${i1.y} A${iR} ${iR} 0 ${lg} 0 ${i2.x} ${i2.y}Z`
}
/**
* LeadStatusChart donut chart with hover interactions, center text display,
* and a legend grid. Shows "No data" when total is 0.
*/
export function LeadStatusChart({ data }: LeadStatusChartProps) {
const [hov, setHov] = useState<number | null>(null)
@@ -1,3 +1,7 @@
// ── Dashboard: Leads Per Month Bar Chart ──
// Interactive SVG bar chart comparing new leads vs. closed leads per month.
// Supports year navigation, hover tooltip with close rate, and gradient-filled bars.
"use client"
import { useState, useMemo, useEffect } from "react"
@@ -6,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { ChevronLeft, ChevronRight } from "lucide-react"
/** A single month's data point */
interface IntervalData {
label: string
leads: number
@@ -16,9 +21,15 @@ interface LeadsPerMonthChartProps {
data: IntervalData[]
}
/** Color constants for new leads and closed bars */
const NEW_LEADS = "#C84B4B"
const CLOSED = "#5A8FC4"
/**
* LeadsPerMonthChart bar chart rendered with SVG.
* Features: year navigation, hover highlight/tooltip, legend, gradient fills,
* grid lines with tick labels, and summary stats (total new, total closed, close rate).
*/
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
const [year, setYear] = useState(new Date().getFullYear())
const [chartData, setChartData] = useState<IntervalData[]>(initialData)
@@ -1,3 +1,7 @@
// ── Dashboard: Recent Leads Table ──
// Compact table showing the most recent leads with company (linked), contact, status badge,
// assigned user avatar, and date. "View all" link navigates to /leads.
"use client"
import Link from "next/link"
@@ -8,6 +12,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Lead } from "@/types"
import { ArrowRight } from "lucide-react"
/** Color-coded status badge classes */
const statusStyles: Record<string, string> = {
open: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
contacted: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
@@ -20,6 +25,10 @@ interface RecentLeadsTableProps {
leads: Lead[]
}
/**
* RecentLeadsTable dashboard widget showing the latest leads in a responsive table.
* Supports hover row highlighting and staggered-row animations.
*/
export function RecentLeadsTable({ leads }: RecentLeadsTableProps) {
return (
<motion.div
+7
View File
@@ -1,3 +1,6 @@
// ── Dashboard: Star Field Background ──
// Decorative fixed-position animated starfield with randomized positions, sizes, and twinkle timings.
"use client"
import { useMemo } from "react"
@@ -10,6 +13,10 @@ interface Star {
duration: number
}
/**
* StarField renders 160 fixed-position stars with randomized animation delays and durations.
* The stars twinkle using the CSS `star-twinkle` animation class.
*/
export function StarField() {
const stars = useMemo<Star[]>(() => {
const result: Star[] = []
@@ -1,7 +1,15 @@
// ── Dashboard: Stat Card Skeleton Loading ──
// Full-width loading placeholder shown while dashboard stats are being fetched.
// Displays "THWIP!" with a loading bar for the Spidey theme, or a generic "Loading your data..." message.
"use client"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
/**
* StatCardSkeleton full-width loading state for dashboard stat cards.
* Shows theme-specific messaging ("THWIP!" for Spidey, default for others).
*/
export function StatCardSkeleton() {
const { websiteTheme } = useWebsiteTheme()
+12
View File
@@ -1,3 +1,7 @@
// ── Dashboard: Stat Card Component ──
// Animated metric card with value counter, icon, trend indicator, optional sparkline chart,
// conversion rate bar, and Spidey-theme hover effects.
"use client"
import { useEffect, useState } from "react"
@@ -19,6 +23,7 @@ interface StatCardProps {
conversionRate?: number
}
/** Per-title color configuration for icon backgrounds, text, glow, and sparkline */
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
"Total Leads": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Open Leads": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
@@ -28,11 +33,13 @@ const cardColors: Record<string, { bg: string; text: string; glow: string; accen
"Conversion Rate": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
}
/** Compute the nearest round goal above the max value */
function computeGoal(max: number): number {
if (max <= 0) return 100
return Math.ceil(max / 100) * 100 || 100
}
/** Generate a smooth Catmull-Rom SVG path string from points */
function smoothPath(points: { x: number; y: number }[]): string {
if (points.length === 0) return ""
if (points.length === 1) return `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`
@@ -50,6 +57,9 @@ function smoothPath(points: { x: number; y: number }[]): string {
return d
}
/**
* StatCard animated dashboard metric with counting effect, sparkline, and themed decoration.
*/
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
const { websiteTheme } = useWebsiteTheme()
const color = cardColors[title] ?? cardColors["Total Leads"]
@@ -58,6 +68,7 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
const [display, setDisplay] = useState(0)
const target = typeof value === "number" ? value : 0
// ── Animated counter: increments from 0 to target over 1 second ──
useEffect(() => {
if (!isNumeric) return
const duration = 1000
@@ -76,6 +87,7 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
return () => clearInterval(timer)
}, [target, isNumeric])
// ── Build sparkline SVG path and grid lines from monthly breakdown data ──
function buildSparklineSvg(): { path: string; area: string; goal: number; gridLines: { y: number }[] } {
if (!monthlyBreakdown || !sparklineField) return { path: "", area: "", goal: 100, gridLines: [] }
const values = monthlyBreakdown.map((m) => m[sparklineField]).reverse()

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