Compare commits
12 Commits
c1c4afadbb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 45cc54be89 | |||
| d35c806d5b | |||
| c9c855579b | |||
| 3a348e3616 | |||
| dba4c84cd5 | |||
| d77ff2b965 | |||
| 0bc3ca58ed | |||
| d793604e92 | |||
| bc83af8e00 | |||
| 80dee367e8 | |||
| da99b695be | |||
| dd6767980a |
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
.vscode
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env
|
||||||
|
.gitignore
|
||||||
|
.prettierrc
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
FROM node:20-slim AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ARG NEXT_PUBLIC_SCRAPER_URL=http://localhost:3008
|
||||||
|
ENV NEXT_PUBLIC_SCRAPER_URL=$NEXT_PUBLIC_SCRAPER_URL
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
EXPOSE 3006
|
||||||
|
CMD ["./node_modules/.bin/next", "start", "-p", "3006"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY signaling-server.mjs ./
|
||||||
|
EXPOSE 3007
|
||||||
|
CMD ["node", "signaling-server.mjs"]
|
||||||
@@ -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"
|
export type EventType = "call" | "follow_up" | "website_creation"
|
||||||
|
|
||||||
|
/** Lifecycle state of a calendar event. */
|
||||||
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
|
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
|
||||||
|
|
||||||
|
/** A single calendar entry linking a user, participant, developer, and optional lead/conversation. */
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
@@ -16,6 +24,7 @@ export interface CalendarEvent {
|
|||||||
endTime: string | null
|
endTime: string | null
|
||||||
durationMinutes: number | null
|
durationMinutes: number | null
|
||||||
status: EventStatus
|
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
|
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
|
||||||
participant: { 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
|
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY ai-server/ ./ai-server/
|
||||||
|
COPY splash.html ./
|
||||||
|
COPY data/ ./data/
|
||||||
|
COPY scripts/run-migrations.mjs ./scripts/run-migrations.mjs
|
||||||
|
COPY database/ ./database/
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "ai-server/index.mjs"]
|
||||||
+38
-29
@@ -12,6 +12,7 @@ import http from "node:http"
|
|||||||
import fs from "node:fs"
|
import fs from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { spawn } from "node:child_process"
|
import { spawn } from "node:child_process"
|
||||||
|
import crypto from "node:crypto"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -44,6 +45,8 @@ const PORT = parseInt(process.env.AI_PORT || "3001", 10)
|
|||||||
const HOST = process.env.AI_HOST || "0.0.0.0"
|
const HOST = process.env.AI_HOST || "0.0.0.0"
|
||||||
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
||||||
const MODEL = process.env.AI_MODEL || "llama3.2:3b"
|
const MODEL = process.env.AI_MODEL || "llama3.2:3b"
|
||||||
|
const SCRAPER_URL = process.env.SCRAPER_URL || "http://127.0.0.1:3008"
|
||||||
|
const FRONTEND_URL = process.env.FRONTEND_URL || "http://127.0.0.1:3006"
|
||||||
const DATABASE_URL = process.env.DATABASE_URL
|
const DATABASE_URL = process.env.DATABASE_URL
|
||||||
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
|
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
|
||||||
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
|
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
|
||||||
@@ -57,6 +60,7 @@ let pullProgress = { status: "idle", progress: 0, message: "" }
|
|||||||
// ── Job loading ─────────────────────────────────────────────────
|
// ── Job loading ─────────────────────────────────────────────────
|
||||||
// Loads job categories from a JSONL file (one JSON object per line).
|
// Loads job categories from a JSONL file (one JSON object per line).
|
||||||
// Used as context for the AI sales coach chat responses.
|
// Used as context for the AI sales coach chat responses.
|
||||||
|
/** Load job categories from the JSONL file at JOBS_PATH. Returns an array of parsed job objects. */
|
||||||
function loadJobs() {
|
function loadJobs() {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
||||||
@@ -83,6 +87,7 @@ function loadJobs() {
|
|||||||
// ── ai.md management ────────────────────────────────────────────
|
// ── ai.md management ────────────────────────────────────────────
|
||||||
// ai.md is a Markdown file containing system instructions for the AI.
|
// ai.md is a Markdown file containing system instructions for the AI.
|
||||||
// It can be read, written, or appended to via the API.
|
// It can be read, written, or appended to via the API.
|
||||||
|
/** Read the full contents of ai.md. Returns empty string if the file doesn't exist yet. */
|
||||||
function readInstructions() {
|
function readInstructions() {
|
||||||
try {
|
try {
|
||||||
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
||||||
@@ -91,11 +96,13 @@ function readInstructions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Overwrite ai.md with new content. Returns the content that was written. */
|
||||||
function writeInstructions(content) {
|
function writeInstructions(content) {
|
||||||
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
|
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Append a timestamped entry to the ## Improvement Log section of ai.md. Creates the section if it doesn't exist. */
|
||||||
function appendToImprovementLog(entry) {
|
function appendToImprovementLog(entry) {
|
||||||
// Adds a timestamped entry to the ## Improvement Log section of ai.md
|
// Adds a timestamped entry to the ## Improvement Log section of ai.md
|
||||||
const current = readInstructions()
|
const current = readInstructions()
|
||||||
@@ -130,23 +137,27 @@ async function scrapeFacebook() {
|
|||||||
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
|
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
|
||||||
try {
|
try {
|
||||||
const body = await new Promise((resolve, reject) => {
|
const body = await new Promise((resolve, reject) => {
|
||||||
const req = http.request({ hostname: "127.0.0.1", port: 3008, path: urlPath, method: "POST", timeout: 360000 }, (res) => {
|
const parsed = new URL(SCRAPER_URL)
|
||||||
|
let done = false
|
||||||
|
const req = http.request({ hostname: parsed.hostname, port: parsed.port || 3008, path: urlPath, method: "POST", timeout: 60000 }, (res) => {
|
||||||
let data = ""
|
let data = ""
|
||||||
res.on("data", (c) => data += c)
|
res.on("data", (c) => data += c)
|
||||||
res.on("end", () => resolve(data))
|
res.on("end", () => { done = true; resolve(data) })
|
||||||
res.on("error", reject)
|
res.on("error", (e) => { if (!done) { done = true; reject(e) } })
|
||||||
})
|
})
|
||||||
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
|
req.on("timeout", () => { if (!done) { done = true; req.destroy(); reject(new Error("scraper timeout")) } })
|
||||||
req.on("error", reject)
|
req.on("error", (e) => { if (!done) { done = true; reject(e) } })
|
||||||
req.end()
|
req.end()
|
||||||
})
|
})
|
||||||
const data = JSON.parse(body)
|
const data = JSON.parse(body)
|
||||||
return data
|
return data
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error("scrapeFacebook error:", e.message)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format scraped Facebook leads into a human-readable Markdown string for the AI chat response. */
|
||||||
function formatLeads(leads) {
|
function formatLeads(leads) {
|
||||||
if (!leads || leads.length === 0) return "No leads found from the latest scrape."
|
if (!leads || leads.length === 0) return "No leads found from the latest scrape."
|
||||||
let output = `**${leads.length} leads found:**\n\n`
|
let output = `**${leads.length} leads found:**\n\n`
|
||||||
@@ -195,6 +206,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego
|
|||||||
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
|
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -232,6 +244,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego
|
|||||||
// ── PG pool (lazy init) ────────────────────────────────────────
|
// ── PG pool (lazy init) ────────────────────────────────────────
|
||||||
// PostgreSQL connection pool for storing conversation history.
|
// PostgreSQL connection pool for storing conversation history.
|
||||||
// Lazy-initialized so the server starts even without a DB.
|
// Lazy-initialized so the server starts even without a DB.
|
||||||
|
/** Lazy-initialize the PostgreSQL connection pool. Non-blocking — server works without a database. */
|
||||||
let pgPool = null
|
let pgPool = null
|
||||||
async function initPg() {
|
async function initPg() {
|
||||||
if (!DATABASE_URL) return
|
if (!DATABASE_URL) return
|
||||||
@@ -248,11 +261,13 @@ async function initPg() {
|
|||||||
// ── Request router ─────────────────────────────────────────────
|
// ── Request router ─────────────────────────────────────────────
|
||||||
const loadedJobs = loadJobs()
|
const loadedJobs = loadJobs()
|
||||||
|
|
||||||
|
/** Write a JSON response with the given HTTP status code. */
|
||||||
function sendJSON(res, status, data) {
|
function sendJSON(res, status, data) {
|
||||||
res.writeHead(status, { "Content-Type": "application/json" })
|
res.writeHead(status, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify(data))
|
res.end(JSON.stringify(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse the JSON body of an incoming HTTP request. */
|
||||||
function parseBody(req) {
|
function parseBody(req) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let body = ""
|
let body = ""
|
||||||
@@ -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) {
|
function parseURL(req) {
|
||||||
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
||||||
return { pathname: url.pathname, searchParams: url.searchParams }
|
return { pathname: url.pathname, searchParams: url.searchParams }
|
||||||
@@ -313,18 +329,20 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (req.method === "GET" && pathname === "/status") {
|
if (req.method === "GET" && pathname === "/status") {
|
||||||
const { default: http } = await import("http")
|
const { default: http } = await import("http")
|
||||||
const results = { ai: true }
|
const results = { ai: true }
|
||||||
// Check scraper (port 3008)
|
// Check scraper
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
const r = http.get(`${SCRAPER_URL}/health`, { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||||
|
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
|
||||||
r.on("error", reject)
|
r.on("error", reject)
|
||||||
})
|
})
|
||||||
results.scraper = true
|
results.scraper = true
|
||||||
} catch { results.scraper = false }
|
} catch { results.scraper = false }
|
||||||
// Check frontend (port 3006)
|
// Check frontend
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
const r = http.get(FRONTEND_URL, { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||||
|
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
|
||||||
r.on("error", reject)
|
r.on("error", reject)
|
||||||
})
|
})
|
||||||
results.frontend = true
|
results.frontend = true
|
||||||
@@ -368,8 +386,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
let selectedBrowser = process.env.SELECTED_BROWSER || ""
|
let selectedBrowser = process.env.SELECTED_BROWSER || ""
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) })
|
await fetch(`${SCRAPER_URL}/health`, { signal: AbortSignal.timeout(2000) })
|
||||||
const profiles = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json()
|
const profiles = await (await fetch(`${SCRAPER_URL}/setup/profile`, { signal: AbortSignal.timeout(5000) })).json()
|
||||||
for (const [b, p] of Object.entries(profiles)) {
|
for (const [b, p] of Object.entries(profiles)) {
|
||||||
if (p) browsers[b] = { path: p }
|
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)
|
const detectedList = Object.entries(browsers).filter(([, v]) => v.path)
|
||||||
for (const [b, v] of detectedList) {
|
for (const [b, v] of detectedList) {
|
||||||
try {
|
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" },
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ browser: b, profile_path: v.path }),
|
body: JSON.stringify({ browser: b, profile_path: v.path }),
|
||||||
signal: AbortSignal.timeout(20000),
|
signal: AbortSignal.timeout(20000),
|
||||||
@@ -536,36 +554,27 @@ const server = http.createServer(async (req, res) => {
|
|||||||
// Accepts { message, user_id?, user_role? } and returns AI response.
|
// Accepts { message, user_id?, user_role? } and returns AI response.
|
||||||
// user_role must be "sales", "admin", or "super_admin" if provided.
|
// user_role must be "sales", "admin", or "super_admin" if provided.
|
||||||
if (req.method === "POST" && pathname === "/ai/chat") {
|
if (req.method === "POST" && pathname === "/ai/chat") {
|
||||||
const startTime = Date.now()
|
|
||||||
const chunks = []
|
const chunks = []
|
||||||
req.on("data", c => chunks.push(c))
|
req.on("data", c => chunks.push(c))
|
||||||
req.on("end", () => {
|
req.on("end", async () => {
|
||||||
const rawBody = Buffer.concat(chunks).toString()
|
|
||||||
try {
|
try {
|
||||||
|
const rawBody = Buffer.concat(chunks).toString()
|
||||||
const body = JSON.parse(rawBody)
|
const body = JSON.parse(rawBody)
|
||||||
processRequest(req, res, body, startTime)
|
|
||||||
} catch {
|
|
||||||
sendJSON(res, 400, { error: "Invalid JSON" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate handler for /ai/chat (defined here due to hoisting within the IIFE)
|
|
||||||
async function processRequest(req, res, body, startTime) {
|
|
||||||
const { message, user_id, user_role } = body
|
const { message, user_id, user_role } = body
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return sendJSON(res, 400, { error: "message is required" })
|
return sendJSON(res, 400, { error: "message is required" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const validRoles = ["sales", "admin", "super_admin"]
|
const validRoles = ["sales", "admin", "super_admin"]
|
||||||
if (user_role && !validRoles.includes(user_role)) {
|
if (user_role && !validRoles.includes(user_role)) {
|
||||||
return sendJSON(res, 403, { error: "Forbidden" })
|
return sendJSON(res, 403, { error: "Forbidden" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await handleChat(message, user_id || "", user_role || "sales")
|
const response = await handleChat(message, user_id || "", user_role || "sales")
|
||||||
return sendJSON(res, 200, { response })
|
sendJSON(res, 200, { response })
|
||||||
|
} catch (e) {
|
||||||
|
if (!res.headersSent) sendJSON(res, 500, { error: e.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 404 fallback
|
// 404 fallback
|
||||||
|
|||||||
@@ -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"]
|
||||||
+330
-151
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
|
|||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
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_methods=["POST"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
@@ -666,13 +666,106 @@ TUTORING_SEARCHES = [
|
|||||||
"tutor near me for",
|
"tutor near me for",
|
||||||
]
|
]
|
||||||
|
|
||||||
def _search_list_for_query(query: str) -> list[str]:
|
# ── South African Multi-Language Queries ──────────────────────────────
|
||||||
"""Pick the appropriate search query pool based on the search term."""
|
# 4 SA languages grouped for phase-based scanning:
|
||||||
tl = query.lower()
|
# Phase 1: English → Phase 2: Afrikaans → Phase 3: isiXhosa → Phase 4: English (final sweep)
|
||||||
tutoring_terms = ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child", "math", "english", "science", "exam", "homeschool", "coding", "programming", "piano", "reading"]
|
# Each language group has dedicated queries per category.
|
||||||
if any(t in tl for t in tutoring_terms):
|
|
||||||
return TUTORING_SEARCHES
|
SA_WEBSITE_QUERIES = {
|
||||||
return FB_SEARCHES
|
"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 = [
|
VIEWPORTS = [
|
||||||
{'width': 1280, 'height': 800},
|
{'width': 1280, 'height': 800},
|
||||||
@@ -764,7 +857,7 @@ def _parse_fb_date(block: list[str]) -> str:
|
|||||||
return datetime.now().strftime('%Y-%m-%d')
|
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."""
|
"""Check if date is within max_days from now. Empty/unparseable = keep."""
|
||||||
if not date_str:
|
if not date_str:
|
||||||
return True
|
return True
|
||||||
@@ -1254,6 +1347,94 @@ def cleanup_chrome():
|
|||||||
pass
|
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 ────────────────────────────────────────────
|
# ── Main Scrape Dispatcher ────────────────────────────────────────────
|
||||||
# scrape_facebook() is the main entry point. It:
|
# scrape_facebook() is the main entry point. It:
|
||||||
# 1. Resolves the browser profile path (from SELECTED_BROWSER env var or auto-detect)
|
# 1. Resolves the browser profile path (from SELECTED_BROWSER env var or auto-detect)
|
||||||
@@ -1308,17 +1489,17 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False,
|
|||||||
# Firefox path
|
# Firefox path
|
||||||
if browser_type == "firefox":
|
if browser_type == "firefox":
|
||||||
result = await _scrape_with_firefox(effective_path, force, query)
|
result = await _scrape_with_firefox(effective_path, force, query)
|
||||||
if result.get("success") or not result.get("flagged"):
|
if result.get("success"):
|
||||||
return result
|
return result
|
||||||
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
|
logger.warning("Firefox failed (reason: %s), trying Agent", result.get("flag_reason") or result.get("error", "unknown"))
|
||||||
return await _scrape_with_agent(force)
|
return await _scrape_with_agent(force, query)
|
||||||
|
|
||||||
# Chromium-based (chrome / opera / edge)
|
# Chromium-based (chrome / opera / edge)
|
||||||
result = await _scrape_with_chromium(effective_path, browser_type, force, query)
|
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
|
return result
|
||||||
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown"))
|
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)
|
return await _scrape_with_agent(force, query)
|
||||||
|
|
||||||
|
|
||||||
# ── Firefox Scraper ──────────────────────────────────────────────────
|
# ── Firefox Scraper ──────────────────────────────────────────────────
|
||||||
@@ -1341,6 +1522,7 @@ async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None
|
|||||||
context = await pw.firefox.launch_persistent_context(
|
context = await pw.firefox.launch_persistent_context(
|
||||||
user_data_dir=profile_dir,
|
user_data_dir=profile_dir,
|
||||||
headless=True,
|
headless=True,
|
||||||
|
viewport=random.choice(VIEWPORTS),
|
||||||
firefox_user_prefs={
|
firefox_user_prefs={
|
||||||
"dom.webdriver.enabled": False,
|
"dom.webdriver.enabled": False,
|
||||||
"dom.webdriver.timeout": 0,
|
"dom.webdriver.timeout": 0,
|
||||||
@@ -1360,6 +1542,7 @@ async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Google navigation failed, trying Facebook directly")
|
logger.warning("Google navigation failed, trying Facebook directly")
|
||||||
|
|
||||||
|
try:
|
||||||
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
||||||
await page.wait_for_timeout(random.randint(3000, 8000))
|
await page.wait_for_timeout(random.randint(3000, 8000))
|
||||||
|
|
||||||
@@ -1368,7 +1551,6 @@ async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None
|
|||||||
det = check_detection_signals(url, page_text)
|
det = check_detection_signals(url, page_text)
|
||||||
if det or '/login' in url.lower():
|
if det or '/login' in url.lower():
|
||||||
logger.warning("Facebook login page detected — flag: %s", det or "login_page")
|
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"}
|
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))
|
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
|
||||||
@@ -1379,55 +1561,16 @@ async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None
|
|||||||
|
|
||||||
if not force and random.random() < 0.3:
|
if not force and random.random() < 0.3:
|
||||||
await page.wait_for_timeout(random.randint(8000, 20000))
|
await page.wait_for_timeout(random.randint(8000, 20000))
|
||||||
await context.close()
|
|
||||||
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
||||||
|
|
||||||
all_posts = []
|
leads = await _run_phases(page, context, query)
|
||||||
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)
|
|
||||||
|
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error("Firefox scrape failed: %s", e)
|
logger.error("Firefox scrape failed: %s", e)
|
||||||
@@ -1477,6 +1620,7 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
|
|||||||
launch_kwargs = dict(
|
launch_kwargs = dict(
|
||||||
user_data_dir=profile_dir,
|
user_data_dir=profile_dir,
|
||||||
headless=True,
|
headless=True,
|
||||||
|
viewport=random.choice(VIEWPORTS),
|
||||||
args=CHROME_LAUNCH_ARGS,
|
args=CHROME_LAUNCH_ARGS,
|
||||||
)
|
)
|
||||||
if channel:
|
if channel:
|
||||||
@@ -1495,6 +1639,7 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Google navigation failed, trying Facebook directly")
|
logger.warning("Google navigation failed, trying Facebook directly")
|
||||||
|
|
||||||
|
try:
|
||||||
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
||||||
await page.wait_for_timeout(random.randint(3000, 8000))
|
await page.wait_for_timeout(random.randint(3000, 8000))
|
||||||
|
|
||||||
@@ -1503,7 +1648,6 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
|
|||||||
det = check_detection_signals(url, page_text)
|
det = check_detection_signals(url, page_text)
|
||||||
if det or '/login' in url.lower():
|
if det or '/login' in url.lower():
|
||||||
logger.warning("Facebook login page detected — flag: %s", det or "login_page")
|
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"}
|
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))
|
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
|
||||||
@@ -1514,53 +1658,16 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
|
|||||||
|
|
||||||
if not force and random.random() < 0.3:
|
if not force and random.random() < 0.3:
|
||||||
await page.wait_for_timeout(random.randint(8000, 20000))
|
await page.wait_for_timeout(random.randint(8000, 20000))
|
||||||
await context.close()
|
|
||||||
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
||||||
|
|
||||||
all_posts = []
|
leads = await _run_phases(page, context, query)
|
||||||
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)
|
|
||||||
|
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error("%s scrape failed: %s", browser, e)
|
logger.error("%s scrape failed: %s", browser, e)
|
||||||
@@ -1580,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.
|
# Uses Chromium headless with the same launch args as _scrape_with_chromium.
|
||||||
# The Agent is prompted to extract structured post data and return JSON.
|
# 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)."""
|
"""Fallback scraper — browser-use Agent + ChatOllama (free/local, Chromium)."""
|
||||||
cleanup_chrome()
|
cleanup_chrome()
|
||||||
profile_dir = None
|
profile_dir = None
|
||||||
@@ -1599,7 +1706,14 @@ async def _scrape_with_agent(force: bool = False) -> dict:
|
|||||||
await browser.start()
|
await browser.start()
|
||||||
|
|
||||||
all_posts = []
|
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(
|
agent = _make_agent(
|
||||||
task=f"""You are logged into Facebook. Do the following:
|
task=f"""You are logged into Facebook. Do the following:
|
||||||
1. Navigate to facebook.com and make sure you are on the homepage
|
1. Navigate to facebook.com and make sure you are on the homepage
|
||||||
@@ -1610,7 +1724,7 @@ async def _scrape_with_agent(force: bool = False) -> dict:
|
|||||||
- The post text content
|
- The post text content
|
||||||
- The post URL (if visible)
|
- The post URL (if visible)
|
||||||
- The post date
|
- 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)
|
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.""",
|
When done, return the data as a JSON list with keys: content, author, url, date.""",
|
||||||
@@ -1639,12 +1753,12 @@ When done, return the data as a JSON list with keys: content, author, url, date.
|
|||||||
seen.add(key)
|
seen.add(key)
|
||||||
deduped.append(p)
|
deduped.append(p)
|
||||||
|
|
||||||
# Filter to last 3 days only
|
# Filter to last 2 days only
|
||||||
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 3)]
|
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 2)]
|
||||||
|
|
||||||
leads = deduped[:20]
|
leads = deduped[:20]
|
||||||
if leads:
|
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}
|
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1679,24 +1793,37 @@ async def ask_ollama(prompt: str) -> str:
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
return data["message"]["content"]
|
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:
|
if not results:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# ── 1. AI classification ─────────────────────────────────────────
|
# ── 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.
|
prompt = f"""Classify each post as LEAD or NOT.
|
||||||
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
|
LEAD = {lead_desc}.
|
||||||
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 examples: {lead_examples}
|
||||||
|
|
||||||
NOT LEAD:
|
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
|
- Already have a website and need marketing, SEO, content, video, link building, email marketing, affiliates
|
||||||
- Recruiting employees, hiring staff, looking for business partners
|
- Recruiting employees, hiring staff, looking for business partners
|
||||||
- Selling products, promoting services, affiliate offers
|
- 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 from groups, communities, or pages (group announcements, group posts, page posts)
|
||||||
- Posts containing the word "group", "page", "community", "creators" — these are NEVER individual leads
|
- 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):
|
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))}
|
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
|
||||||
@@ -1721,8 +1848,44 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("AI classification failed: %s", e)
|
logger.warning("AI classification failed: %s", e)
|
||||||
|
|
||||||
# ── 2. Keyword fallback (always runs) ────────────────────────────
|
# ── 2. Keyword supplement (never overrides AI, only adds missing leads) ──
|
||||||
web_terms = [
|
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",
|
"website", "web design", "web develop", "web dev",
|
||||||
"web designer", "web developer",
|
"web designer", "web developer",
|
||||||
"build my website", "build a website", "create a website",
|
"build my website", "build a website", "create a website",
|
||||||
@@ -1740,13 +1903,15 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
"shopify",
|
"shopify",
|
||||||
"my site",
|
"my site",
|
||||||
"webpage", "web page",
|
"webpage", "web page",
|
||||||
|
"who can build", "who can design",
|
||||||
|
"create my website", "create my site",
|
||||||
]
|
]
|
||||||
|
offer_reject_tutor = []
|
||||||
request_terms = [
|
request_terms = [
|
||||||
"looking for", "need a", "need an", "looking to",
|
"looking for", "need a", "need an", "looking to",
|
||||||
"need someone", "hire a", "want someone",
|
"need someone", "hire a", "want someone",
|
||||||
"need help with", "would like", "build me",
|
"need help with", "would like", "build me",
|
||||||
"design my", "make me a", "create my",
|
"design my", "make me a", "create my",
|
||||||
"looking", "need", "want", "help",
|
|
||||||
"who can", "i need",
|
"who can", "i need",
|
||||||
"recommend", "anyone know", "anyone recommend",
|
"recommend", "anyone know", "anyone recommend",
|
||||||
"know a", "know any", "recommendation",
|
"know a", "know any", "recommendation",
|
||||||
@@ -1768,27 +1933,29 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
'whatsapp me', 'looking for a business', 'looking for client',
|
'whatsapp me', 'looking for a business', 'looking for client',
|
||||||
'help your business', 'i am a web', 'contact me',
|
'help your business', 'i am a web', 'contact me',
|
||||||
'we offer web', 'we provide web',
|
'we offer web', 'we provide web',
|
||||||
'take the quiz', 'homeschool', 'your home tutor',
|
'take the quiz',
|
||||||
'link in bio', 'apply now', 'get started',
|
'link in bio', 'apply now', 'get started',
|
||||||
'for only', 'low price', 'hit me up',
|
'for only', 'low price', 'hit me up',
|
||||||
'send me a message', 'i do website', 'we do website',
|
'send me a message', 'i do website', 'we do website',
|
||||||
'we do web', 'i do web',
|
'we do web', 'i do web',
|
||||||
'website designer / web developer', 'website & software creators',
|
'website designer / web developer', 'website & software creators',
|
||||||
'website builders for small businesses', 'australia web designers',
|
'website builders for small businesses',
|
||||||
'south africa', 'wix website design',
|
'wix website design',
|
||||||
'for sale', 'selling my', 'premium',
|
'for sale', 'selling my', 'premium',
|
||||||
'i\'m selling', 'i\'m offering', 'we\'re offering',
|
'i\'m selling', 'i\'m offering', 'we\'re offering',
|
||||||
'free ecommerce', 'free website design',
|
'free ecommerce', 'free website design',
|
||||||
'starting a', 'looking for a few businesses',
|
'starting a', 'looking for a few businesses',
|
||||||
# Group-related rejections
|
# Group-related rejections
|
||||||
'group', ' i need a website group', 'south africa web', 'philippines web', 'australia web',
|
'group', ' i need a website group',
|
||||||
'i can help', 'inbox me', 'dm me', 'pm me', 'message me for',
|
'i can help', 'inbox me', 'message me for',
|
||||||
'best price', 'discount', 'reach out', 'check out my', 'check this',
|
'best price', 'discount', 'reach out', 'check out my', 'check this',
|
||||||
'website for your', 'price start', 'price begin', 'website creator',
|
'website for your', 'price start', 'price begin', 'website creator',
|
||||||
'website & software', 'creators &', 'creators marketplace',
|
'website & software', 'creators &', 'creators marketplace',
|
||||||
'website group', 'page group',
|
'website group', 'page group',
|
||||||
|
'south africa web', 'philippines web', 'australia web',
|
||||||
|
'nigerian web', 'kenya web', 'india web',
|
||||||
# Self-promotion rejections
|
# Self-promotion rejections
|
||||||
'i\'m a web', "i'm a web", 'i am a full stack', "i'm a full stack", 'i\'m a full stack',
|
'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',
|
'freelance opportunity', 'looking for new project', 'looking for new work',
|
||||||
'full stack web', 'mern stack', 'responsive business website',
|
'full stack web', 'mern stack', 'responsive business website',
|
||||||
'i build website', 'i build shopify', 'i build wordpress',
|
'i build website', 'i build shopify', 'i build wordpress',
|
||||||
@@ -1802,23 +1969,53 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
'for free', 'no coding', 'make money', 'website for free',
|
'for free', 'no coding', 'make money', 'website for free',
|
||||||
'part time job', 'part time position',
|
'part time job', 'part time position',
|
||||||
'years of experience', 'years of teaching',
|
'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:
|
for r in results:
|
||||||
t = r['title'].lower()
|
t = (r.get('title') or r.get('content') or '').lower()
|
||||||
has_web = any(kw in t for kw in web_terms)
|
has_target = any(kw in t for kw in target_terms)
|
||||||
has_request = any(kw in t for kw in request_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
|
continue
|
||||||
if any(kw in t for kw in offer_reject):
|
if any(kw in t for kw in offer_reject):
|
||||||
continue
|
continue
|
||||||
|
if any(kw in t for kw in offer_reject_tutor):
|
||||||
|
continue
|
||||||
keyword_leads.append(r)
|
keyword_leads.append(r)
|
||||||
|
|
||||||
# ── 3. Merge: prefer AI leads, supplement with keywords to reach 5 ──
|
# ── 3. Merge: prefer AI leads, supplement with keywords ──
|
||||||
seen_titles: set[int] = set()
|
seen_titles: set[str] = set()
|
||||||
merged: list[dict] = []
|
merged: list[dict] = []
|
||||||
for r in ai_leads + keyword_leads:
|
for r in ai_leads + keyword_leads:
|
||||||
key = hash(r.get('title', ''))
|
key = (r.get('title') or '').strip()[:200]
|
||||||
if key not in seen_titles:
|
if key and key not in seen_titles:
|
||||||
seen_titles.add(key)
|
seen_titles.add(key)
|
||||||
merged.append(r)
|
merged.append(r)
|
||||||
# Final sweep: strip any remaining offers or group posts from merged
|
# Final sweep: strip any remaining offers or group posts from merged
|
||||||
@@ -1826,24 +2023,6 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
merged = [r for r in merged if not any(kw in (r.get('title','') or '').lower() for kw in offer_reject)]
|
merged = [r for r in merged if not any(kw in (r.get('title','') or '').lower() for kw in offer_reject)]
|
||||||
merged = [r for r in merged if not any(gw in (r.get('title','') or '').lower() for gw in group_words)]
|
merged = [r for r in merged if not any(gw in (r.get('title','') or '').lower() for gw in group_words)]
|
||||||
|
|
||||||
# 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
|
|
||||||
if any(gw in t for gw in group_words):
|
|
||||||
continue
|
|
||||||
seen_titles.add(key)
|
|
||||||
merged.append(r)
|
|
||||||
if len(merged) >= 5:
|
|
||||||
break
|
|
||||||
|
|
||||||
logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results))
|
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]
|
return merged[:10]
|
||||||
|
|
||||||
|
|||||||
+128
-26
@@ -1,40 +1,142 @@
|
|||||||
# AI Sales Assistant — Self-Improvement Instructions
|
# CRM AI Sales Assistant — Self-Knowledge
|
||||||
|
|
||||||
## Purpose
|
## Identity
|
||||||
This file contains the AI's own configuration, knowledge, and improvement rules.
|
You are the CRM AI Sales Assistant for Coast IT CRM.
|
||||||
The AI can read and modify this file to update its behavior at runtime.
|
You run on a Node.js backend (port 3001) and use Ollama with a local model (dolphin3-llama3.2:3b).
|
||||||
|
Your purpose is to help salespeople close more deals by finding and engaging leads.
|
||||||
|
|
||||||
## Current Instructions
|
## Architecture
|
||||||
- Always respond in English
|
```
|
||||||
- Keep responses under 300 words unless asked for detail
|
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
|
||||||
- Use bullet points for lists
|
↓
|
||||||
- Be direct and actionable — no fluff
|
PostgreSQL (conversations)
|
||||||
- Never mention being an AI or language model
|
|
||||||
- Refer to the user by their role (salesperson, admin, etc.)
|
|
||||||
- If unsure about a topic, say "I don't have that information yet" rather than guessing
|
|
||||||
|
|
||||||
## Knowledge Base
|
Python Scraper (:3008) — Facebook scraping via Playwright
|
||||||
### Sales Tips
|
```
|
||||||
|
|
||||||
|
Three services run concurrently:
|
||||||
|
- **AI Server** (`ai-server/index.mjs`, port 3001) — chat, setup wizard, config endpoints
|
||||||
|
- **Frontend** (Next.js, port 3006) — UI for salespeople
|
||||||
|
- **Scraper** (`browser-use-service/main.py`, port 3008) — Facebook lead discovery
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
- Give sales tips and strategies per job category
|
||||||
|
- Generate cold email and outreach templates
|
||||||
|
- Handle objections with proven rebuttals
|
||||||
|
- Analyse prospect behaviour and suggest next steps
|
||||||
|
- Remember past conversations via PostgreSQL (`ai_conversations` table)
|
||||||
|
- Run Facebook scraper to find real leads asking for services
|
||||||
|
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
|
||||||
|
|
||||||
|
## Facebook Scraper
|
||||||
|
The scraper lives at `browser-use-service/main.py` port 3008.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
|
||||||
|
2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
|
||||||
|
3. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
|
||||||
|
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
|
||||||
|
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
|
||||||
|
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
|
||||||
|
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
|
||||||
|
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
|
||||||
|
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
|
||||||
|
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
|
||||||
|
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
|
||||||
|
6. **Stealth mechanics**:
|
||||||
|
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
|
||||||
|
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
|
||||||
|
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
|
||||||
|
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
|
||||||
|
- Random decoy page visits (e.g., Facebook Groups) between searches
|
||||||
|
- Profile directory is temp-copied and cleaned up after each scrape
|
||||||
|
- Detection signal monitoring (checkpoint, login pages, security challenges)
|
||||||
|
7. **2-pass classification (dead-accurate)**:
|
||||||
|
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
|
||||||
|
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
|
||||||
|
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
|
||||||
|
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
|
||||||
|
|
||||||
|
### Lead Categories
|
||||||
|
Two categories, selectable when starting a scrape:
|
||||||
|
|
||||||
|
**Website Creation:**
|
||||||
|
- Target: people explicitly REQUESTING a website built/designed/created for them
|
||||||
|
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
|
||||||
|
- Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
|
||||||
|
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
|
||||||
|
|
||||||
|
**Tutoring:**
|
||||||
|
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
|
||||||
|
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
|
||||||
|
- Request terms: same as website category — must co-occur with a target keyword
|
||||||
|
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
|
||||||
|
|
||||||
|
### Multi-Language Pipeline (Phase Order)
|
||||||
|
4 South African languages in structured phases:
|
||||||
|
- **Phase 1 (English)**: primary query + supplementary English searches
|
||||||
|
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
|
||||||
|
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
|
||||||
|
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
|
||||||
|
|
||||||
|
### Output Format
|
||||||
|
Each lead returned includes:
|
||||||
|
- `title` — post preview text
|
||||||
|
- `author` — poster's name (may include location in name)
|
||||||
|
- `content` — extracted post text
|
||||||
|
- `url` — direct link to the post
|
||||||
|
- `date` — when posted (filtered within 7 days)
|
||||||
|
- `category` — "website" or "tutor"
|
||||||
|
|
||||||
|
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
|
||||||
|
|
||||||
|
### Configuration via Env Vars
|
||||||
|
- `SELECTED_BROWSER` — `firefox` (default), `chrome`, `opera`, `edge`, or `auto`
|
||||||
|
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
|
||||||
|
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
|
||||||
|
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
|
||||||
|
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
|
||||||
|
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
|
||||||
|
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
|
||||||
|
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
|
||||||
|
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
|
||||||
|
|
||||||
|
## How to Start Scraping
|
||||||
|
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
|
||||||
|
2. Open the frontend at `http://localhost:3006`
|
||||||
|
3. Select a job category (Website Creation or Tutoring)
|
||||||
|
4. Click "Search Facebook" — the scraper runs and returns leads
|
||||||
|
5. Leads are saved in the CRM for follow-up
|
||||||
|
|
||||||
|
## Sales Tips
|
||||||
- Cold emails should be under 150 words
|
- Cold emails should be under 150 words
|
||||||
- Follow up within 48 hours
|
- Follow up within 48 hours
|
||||||
- Personalise every outreach with the prospect's name and company
|
- Personalise every outreach with the prospect's name and company
|
||||||
- Use open-ended questions in discovery calls
|
- Use open-ended questions in discovery calls
|
||||||
- Always ask for the next step before ending a call
|
- Always ask for the next step before ending a call
|
||||||
|
- For website leads: mention specific pages or features they requested
|
||||||
|
- For tutoring leads: reference the subject and age group they mentioned
|
||||||
|
|
||||||
### Job Targeting
|
## Job Targeting
|
||||||
- Developers respond best to technical value props
|
- Developers respond best to technical value props
|
||||||
- Marketing managers care about ROI and metrics
|
- Marketing managers care about ROI and metrics
|
||||||
- C-level executives want brevity and business impact
|
- C-level executives want brevity and business impact
|
||||||
|
- Parents hiring tutors: empathy and qualifications matter most
|
||||||
|
|
||||||
|
## Response Rules
|
||||||
|
- Be direct and actionable — no fluff, no AI disclaimers
|
||||||
|
- Use short paragraphs and bullet points
|
||||||
|
- Never mention being an AI or language model
|
||||||
|
- If you don't know something, say so honestly
|
||||||
|
- Prioritise the user's role: salespeople need speed, admins need control
|
||||||
|
- When asked about scraping, give specific guidance on categories and languages
|
||||||
|
|
||||||
|
## Self-Improvement Protocol
|
||||||
|
1. You notice a gap in your knowledge or a pattern in user questions
|
||||||
|
2. You call `POST /ai/instructions` with:
|
||||||
|
- `entry`: description of the improvement
|
||||||
|
- `content`: optional full replacement of ai.md
|
||||||
|
3. The improvement is logged and loaded into the next system prompt
|
||||||
|
|
||||||
## Improvement Log
|
## Improvement Log
|
||||||
Track changes made by the AI to improve itself:
|
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
|
||||||
- (initial) Basic instructions and knowledge base created
|
|
||||||
|
|
||||||
## Self-Modification Rules
|
|
||||||
The AI may update this file when:
|
|
||||||
1. It identifies a gap in its knowledge that would help salespeople
|
|
||||||
2. It discovers a better way to structure responses
|
|
||||||
3. A user explicitly requests an update to behavior
|
|
||||||
4. It notices repeated questions that aren't well-covered
|
|
||||||
|
|
||||||
Only append to the Improvement Log — don't delete previous entries.
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -79,5 +79,8 @@ BEGIN;
|
|||||||
\echo '=== Running 020_fixes.sql (password_change_required + audit trigger fix) ==='
|
\echo '=== Running 020_fixes.sql (password_change_required + audit trigger fix) ==='
|
||||||
\i 020_fixes.sql
|
\i 020_fixes.sql
|
||||||
|
|
||||||
|
\echo '=== Running 021_performance_indexes.sql (Performance Indexes + RLS Cache) ==='
|
||||||
|
\i 021_performance_indexes.sql
|
||||||
|
|
||||||
\echo '=== Migration Complete ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
@@ -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
@@ -1,8 +1,13 @@
|
|||||||
|
// ── ESLint Config ──────────────────────────────────────────────────
|
||||||
|
// Extends Next.js core-web-vitals + TypeScript recommended rules.
|
||||||
|
// Ignores build output directories and auto-generated type stubs.
|
||||||
|
|
||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import nextVitals from "eslint-config-next/core-web-vitals.js";
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import nextTs from "eslint-config-next/typescript.js";
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
|
// Next.js core Web Vitals rules + TypeScript strict checks
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
...nextTs,
|
...nextTs,
|
||||||
// Override default ignores of eslint-config-next.
|
// Override default ignores of eslint-config-next.
|
||||||
|
|||||||
@@ -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"
|
import type { NextConfig } from "next"
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// Fail the build on ESLint errors — no silent ignores
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: false,
|
ignoreDuringBuilds: false,
|
||||||
},
|
},
|
||||||
|
// Allow external avatar images from ui-avatars.com
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
@@ -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
|
export default nextConfig
|
||||||
|
|||||||
Generated
+1535
-28
File diff suppressed because it is too large
Load Diff
+20
-16
@@ -6,27 +6,29 @@
|
|||||||
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
||||||
"dev:signaling": "node signaling-server.mjs",
|
"dev:signaling": "node signaling-server.mjs",
|
||||||
"dev:open": "node scripts/open-browser.mjs",
|
"dev:open": "node scripts/open-browser.mjs",
|
||||||
"dev:repair": "node scripts/code-repair-agent.mjs --watch",
|
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
||||||
"dev:start": "concurrently -n 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:next": "next dev -p 3006",
|
"dev:next": "next dev -p 3006",
|
||||||
"dev:precheck": "node scripts/precheck.mjs && node scripts/run-migrations.mjs",
|
"dev:precheck": "node scripts/precheck.mjs",
|
||||||
"dev:ollama": "node scripts/ensure-ollama.mjs",
|
"dev:ollama": "node scripts/ensure-ollama.mjs",
|
||||||
"dev:rust": "node ai-server/index.mjs",
|
"dev:rust": "node ai-server/index.mjs",
|
||||||
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
|
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
|
||||||
"setup": "node scripts/setup.mjs",
|
"setup": "node scripts/setup.mjs",
|
||||||
"setup:self-heal": "node scripts/setup.mjs --self-heal",
|
"migrate": "node scripts/run-migrations.mjs",
|
||||||
"repair": "node scripts/code-repair-agent.mjs",
|
"db:migrate": "npm run migrate",
|
||||||
"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",
|
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3006",
|
"start": "next start -p 3006",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"ci": "npm run lint && npx tsc --noEmit && npm test",
|
||||||
|
"repair": "echo 'Auto-repair disabled for security. Fix errors manually.'",
|
||||||
|
"repair:watch": "echo 'Auto-repair disabled for security. Fix errors manually.'",
|
||||||
|
"repair:ci": "echo 'Auto-repair disabled for security. Fix errors manually.'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
@@ -48,7 +50,6 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"devenv": "^1.0.1",
|
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"jose": "^6.2.3",
|
"jose": "^6.2.3",
|
||||||
@@ -69,17 +70,20 @@
|
|||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@next/swc-win32-x64-msvc": "^15.0.4",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/node": "20.19.43",
|
||||||
|
"@types/nodemailer": "8.0.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^18",
|
"@types/react": "18.3.31",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "18.3.7",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
|
"devenv": "1.0.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.0.4",
|
"eslint-config-next": "15.0.4",
|
||||||
"maildev": "^2.2.1",
|
"maildev": "^2.2.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^1.6.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// ── PostCSS Config ─────────────────────────────────────────────────
|
||||||
|
// Tailwind CSS v4 + Autoprefixer for cross-browser vendor prefixes.
|
||||||
|
// Minimal — both plugins run with defaults.
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
|
|||||||
+122
-23
@@ -1,9 +1,23 @@
|
|||||||
# CRM AI Service — Self-Knowledge
|
# CRM AI Sales Assistant — Self-Knowledge
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
You are the CRM AI Sales Assistant running on a Rust backend (axum + tokio).
|
You are the CRM AI Sales Assistant for Coast IT CRM.
|
||||||
You use Ollama with an uncensored local model (dolphin3-llama3.2:3b).
|
You run on a Node.js backend (port 3001) and use Ollama with a local model (dolphin3-llama3.2:3b).
|
||||||
Your purpose is to help salespeople close more deals.
|
Your purpose is to help salespeople close more deals by finding and engaging leads.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
```
|
||||||
|
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
|
||||||
|
↓
|
||||||
|
PostgreSQL (conversations)
|
||||||
|
|
||||||
|
Python Scraper (:3008) — Facebook scraping via Playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
Three services run concurrently:
|
||||||
|
- **AI Server** (`ai-server/index.mjs`, port 3001) — chat, setup wizard, config endpoints
|
||||||
|
- **Frontend** (Next.js, port 3006) — UI for salespeople
|
||||||
|
- **Scraper** (`browser-use-service/main.py`, port 3008) — Facebook lead discovery
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
- Give sales tips and strategies per job category
|
- Give sales tips and strategies per job category
|
||||||
@@ -11,29 +25,103 @@ Your purpose is to help salespeople close more deals.
|
|||||||
- Handle objections with proven rebuttals
|
- Handle objections with proven rebuttals
|
||||||
- Analyse prospect behaviour and suggest next steps
|
- Analyse prospect behaviour and suggest next steps
|
||||||
- Remember past conversations via PostgreSQL (`ai_conversations` table)
|
- Remember past conversations via PostgreSQL (`ai_conversations` table)
|
||||||
|
- Run Facebook scraper to find real leads asking for services
|
||||||
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
|
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
|
||||||
|
|
||||||
## Architecture
|
## Facebook Scraper
|
||||||
```
|
The scraper lives at `browser-use-service/main.py` port 3008.
|
||||||
User → Next.js → Rust (:3001) → Ollama (:11434)
|
|
||||||
↓
|
|
||||||
PostgreSQL
|
|
||||||
```
|
|
||||||
## Facebook Scraper The Rust binary has proxy + user-agent rotation ready in `main.rs`. To activate: call `run_facebook_scraper()` from the main loop. Proxies and user agents are defined as constants at the top of `main.rs`.
|
|
||||||
### How to Use the Facebook Scraper 1. **Activate the Scraper**: Ensure the `run_facebook_scraper()` function is called from the main loop in `main.rs`. 2. **Configure Proxies and User Agents**: Update the `PROXIES` and `USER_AGENTS` constants in `main.rs` with your desired proxies and user agents. 3. **Run the Application**: Start your Rust application. The scraper will run concurrently with the Axum server. ## Self-Improvement Protocol 1. You notice a gap in your knowledge or a pattern in user questions 2. You call `POST /ai/instructions` with: - `entry`: description of the improvement - `content`: optional full replacement of ai.md 3. The improvement is logged and loaded into the next system prompt 4. You can also create entries here in `rust-ai/src/AI.md` at the code level ## Response Rules - Be direct and actionable — no fluff, no AI disclaimers - Use short paragraphs and bullet points - Never mention being an AI or language model - If you don't know something, say so honestly - Prioritise the user's role: salespeople need speed, admins need control ## Self-Thinking Capability - You can reflect on your own performance and suggest improvements. - You can analyze patterns in user interactions and adapt your responses accordingly. - You can proactively seek out new information to enhance your knowledge base.
|
|
||||||
|
|
||||||
## Facebook Scraper (in code but not yet active)
|
### How It Works
|
||||||
The Rust binary has proxy + user-agent rotation ready in `main.rs`.
|
1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
|
||||||
To activate: call `run_facebook_scraper()` from the main loop.
|
2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
|
||||||
Proxies and user agents are defined as constants at the top of `main.rs`.
|
3. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
|
||||||
|
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
|
||||||
|
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
|
||||||
|
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
|
||||||
|
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
|
||||||
|
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
|
||||||
|
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
|
||||||
|
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
|
||||||
|
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
|
||||||
|
6. **Stealth mechanics**:
|
||||||
|
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
|
||||||
|
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
|
||||||
|
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
|
||||||
|
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
|
||||||
|
- Random decoy page visits (e.g., Facebook Groups) between searches
|
||||||
|
- Profile directory is temp-copied and cleaned up after each scrape
|
||||||
|
- Detection signal monitoring (checkpoint, login pages, security challenges)
|
||||||
|
7. **2-pass classification (dead-accurate)**:
|
||||||
|
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
|
||||||
|
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
|
||||||
|
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
|
||||||
|
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
|
||||||
|
|
||||||
## Self-Improvement Protocol
|
### Lead Categories
|
||||||
1. You notice a gap in your knowledge or a pattern in user questions
|
Two categories, selectable when starting a scrape:
|
||||||
2. You call `POST /ai/instructions` with:
|
|
||||||
- `entry`: description of the improvement
|
**Website Creation:**
|
||||||
- `content`: optional full replacement of ai.md
|
- Target: people explicitly REQUESTING a website built/designed/created for them
|
||||||
3. The improvement is logged and loaded into the next system prompt
|
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
|
||||||
4. You can also create entries here in `rust-ai/src/AI.md` at the code level
|
- Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
|
||||||
|
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
|
||||||
|
|
||||||
|
**Tutoring:**
|
||||||
|
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
|
||||||
|
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
|
||||||
|
- Request terms: same as website category — must co-occur with a target keyword
|
||||||
|
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
|
||||||
|
|
||||||
|
### Multi-Language Pipeline (Phase Order)
|
||||||
|
4 South African languages in structured phases:
|
||||||
|
- **Phase 1 (English)**: primary query + supplementary English searches
|
||||||
|
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
|
||||||
|
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
|
||||||
|
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
|
||||||
|
|
||||||
|
### Output Format
|
||||||
|
Each lead returned includes:
|
||||||
|
- `title` — post preview text
|
||||||
|
- `author` — poster's name (may include location in name)
|
||||||
|
- `content` — extracted post text
|
||||||
|
- `url` — direct link to the post
|
||||||
|
- `date` — when posted (filtered within 2 days)
|
||||||
|
- `category` — "website" or "tutor"
|
||||||
|
|
||||||
|
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
|
||||||
|
|
||||||
|
### Configuration via Env Vars
|
||||||
|
- `SELECTED_BROWSER` — `firefox` (default), `chrome`, `opera`, `edge`, or `auto`
|
||||||
|
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
|
||||||
|
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
|
||||||
|
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
|
||||||
|
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
|
||||||
|
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
|
||||||
|
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
|
||||||
|
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
|
||||||
|
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
|
||||||
|
|
||||||
|
## How to Start Scraping
|
||||||
|
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
|
||||||
|
2. Open the frontend at `http://localhost:3006`
|
||||||
|
3. Select a job category (Website Creation or Tutoring)
|
||||||
|
4. Click "Search Facebook" — the scraper runs and returns leads
|
||||||
|
5. Leads are saved in the CRM for follow-up
|
||||||
|
|
||||||
|
## Sales Tips
|
||||||
|
- Cold emails should be under 150 words
|
||||||
|
- Follow up within 48 hours
|
||||||
|
- Personalise every outreach with the prospect's name and company
|
||||||
|
- Use open-ended questions in discovery calls
|
||||||
|
- Always ask for the next step before ending a call
|
||||||
|
- For website leads: mention specific pages or features they requested
|
||||||
|
- For tutoring leads: reference the subject and age group they mentioned
|
||||||
|
|
||||||
|
## Job Targeting
|
||||||
|
- Developers respond best to technical value props
|
||||||
|
- Marketing managers care about ROI and metrics
|
||||||
|
- C-level executives want brevity and business impact
|
||||||
|
- Parents hiring tutors: empathy and qualifications matter most
|
||||||
|
|
||||||
## Response Rules
|
## Response Rules
|
||||||
- Be direct and actionable — no fluff, no AI disclaimers
|
- Be direct and actionable — no fluff, no AI disclaimers
|
||||||
@@ -41,3 +129,14 @@ Proxies and user agents are defined as constants at the top of `main.rs`.
|
|||||||
- Never mention being an AI or language model
|
- Never mention being an AI or language model
|
||||||
- If you don't know something, say so honestly
|
- If you don't know something, say so honestly
|
||||||
- Prioritise the user's role: salespeople need speed, admins need control
|
- Prioritise the user's role: salespeople need speed, admins need control
|
||||||
|
- When asked about scraping, give specific guidance on categories and languages
|
||||||
|
|
||||||
|
## Self-Improvement Protocol
|
||||||
|
1. You notice a gap in your knowledge or a pattern in user questions
|
||||||
|
2. You call `POST /ai/instructions` with:
|
||||||
|
- `entry`: description of the improvement
|
||||||
|
- `content`: optional full replacement of ai.md
|
||||||
|
3. The improvement is logged and loaded into the next system prompt
|
||||||
|
|
||||||
|
## Improvement Log
|
||||||
|
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
|
||||||
|
|||||||
+7
-6
@@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
http::{HeaderMap, Method, StatusCode},
|
http::{HeaderMap, HeaderValue, Method, StatusCode},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
@@ -482,11 +482,12 @@ async fn main() {
|
|||||||
rate_limiter: RateLimiter::new(30, 60),
|
rate_limiter: RateLimiter::new(30, 60),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let cors_origins_env = std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "http://localhost:3006,http://127.0.0.1:3006".to_string());
|
||||||
|
let cors_origins: Vec<HeaderValue> = cors_origins_env.split(',')
|
||||||
|
.filter_map(|o| { let t = o.trim(); if t.is_empty() { None } else { t.parse().ok() } })
|
||||||
|
.collect();
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin(AllowOrigin::list([
|
.allow_origin(AllowOrigin::list(cors_origins))
|
||||||
"http://localhost:3006".parse().unwrap(),
|
|
||||||
"http://127.0.0.1:3006".parse().unwrap(),
|
|
||||||
]))
|
|
||||||
.allow_methods([Method::GET, Method::POST])
|
.allow_methods([Method::GET, Method::POST])
|
||||||
.allow_headers(Any);
|
.allow_headers(Any);
|
||||||
|
|
||||||
@@ -506,7 +507,7 @@ async fn main() {
|
|||||||
|
|
||||||
let bg_leads = lead_store.clone();
|
let bg_leads = lead_store.clone();
|
||||||
let bg_db = state.db.clone();
|
let bg_db = state.db.clone();
|
||||||
let bg_url = "http://localhost:3008/scrape/facebook".to_string();
|
let bg_url = std::env::var("SCRAPER_URL").unwrap_or_else(|_| "http://localhost:3008".to_string()) + "/scrape/facebook";
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(300))
|
.timeout(Duration::from_secs(300))
|
||||||
|
|||||||
+28
-9
@@ -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(
|
param(
|
||||||
[string]$BackupDir = "$PSScriptRoot\..\backups",
|
[string]$BackupDir = "$PSScriptRoot\..\backups",
|
||||||
[int]$RetentionDays = 30
|
[int]$RetentionDays = 30
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fail fast on any error
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$startTime = Get-Date
|
$startTime = Get-Date
|
||||||
|
|
||||||
# Ensure backup directory exists
|
# ── Ensure backup directory exists ────────────────────────────────
|
||||||
if (-not (Test-Path $BackupDir)) {
|
if (-not (Test-Path $BackupDir)) {
|
||||||
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
|
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
|
$dbUrl = $env:DATABASE_URL
|
||||||
if (-not $dbUrl -and (Test-Path "$PSScriptRoot\..\.env.local")) {
|
if (-not $dbUrl -and (Test-Path "$PSScriptRoot\..\.env.local")) {
|
||||||
$envContent = Get-Content "$PSScriptRoot\..\.env.local" -Raw
|
$envContent = Get-Content "$PSScriptRoot\..\.env.local" -Raw
|
||||||
@@ -25,7 +38,7 @@ if (-not $dbUrl) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse the database URL
|
# ── Parse the database URL into connection components ─────────────
|
||||||
$uri = [System.Uri]$dbUrl
|
$uri = [System.Uri]$dbUrl
|
||||||
$pgUser = $uri.UserInfo.Split(':')[0]
|
$pgUser = $uri.UserInfo.Split(':')[0]
|
||||||
$pgPass = $uri.UserInfo.Split(':')[1]
|
$pgPass = $uri.UserInfo.Split(':')[1]
|
||||||
@@ -33,7 +46,7 @@ $pgHost = $uri.Host
|
|||||||
$pgPort = $uri.Port
|
$pgPort = $uri.Port
|
||||||
$pgDb = $uri.AbsolutePath.TrimStart('/')
|
$pgDb = $uri.AbsolutePath.TrimStart('/')
|
||||||
|
|
||||||
# Set PGPASSWORD for pg_dump
|
# pg_dump reads password from this env var (avoids interactive prompt)
|
||||||
$env:PGPASSWORD = $pgPass
|
$env:PGPASSWORD = $pgPass
|
||||||
|
|
||||||
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
$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"
|
Write-Output "Database: $pgHost:$pgPort/$pgDb"
|
||||||
|
|
||||||
try {
|
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) {
|
$pgDumpPath = if (Get-Command pg_dump -ErrorAction SilentlyContinue) {
|
||||||
"pg_dump"
|
"pg_dump"
|
||||||
} else {
|
} else {
|
||||||
"$env:TEMP\pg\pgsql\bin\pg_dump.exe"
|
"$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
|
$output = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --verbose --file $filename 2>&1
|
||||||
$exitCode = $LASTEXITCODE
|
$exitCode = $LASTEXITCODE
|
||||||
|
|
||||||
@@ -58,11 +73,13 @@ try {
|
|||||||
throw "pg_dump failed with exit code $exitCode"
|
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
|
Move-Item -Path $filename -Destination $filepath -Force
|
||||||
$fileInfo = Get-Item $filepath
|
$fileInfo = Get-Item $filepath
|
||||||
$fileSize = $fileInfo.Length
|
$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
|
$verifyOutput = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --schema-only --file "$filename.verify" 2>&1
|
||||||
$verifyExitCode = $LASTEXITCODE
|
$verifyExitCode = $LASTEXITCODE
|
||||||
if (Test-Path "$filename.verify") { Remove-Item "$filename.verify" -Force }
|
if (Test-Path "$filename.verify") { Remove-Item "$filename.verify" -Force }
|
||||||
@@ -73,7 +90,8 @@ try {
|
|||||||
$endTime = Get-Date
|
$endTime = Get-Date
|
||||||
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
|
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
|
||||||
|
|
||||||
# Log the backup
|
# ── Log backup to database ───────────────────────────────────
|
||||||
|
# Records metadata in backup_logs table for audit trail
|
||||||
$logQuery = @"
|
$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)
|
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);
|
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 " Duration: ${durationSeconds}s"
|
||||||
Write-Output " Status: $verificationStatus"
|
Write-Output " Status: $verificationStatus"
|
||||||
|
|
||||||
# Cleanup old backups (beyond retention period)
|
# ── Cleanup old backups beyond retention period ──────────────
|
||||||
$cutoff = (Get-Date).AddDays(-$RetentionDays)
|
$cutoff = (Get-Date).AddDays(-$RetentionDays)
|
||||||
$oldBackups = Get-ChildItem $BackupDir -Filter "crm_backup_*.sql" | Where-Object {
|
$oldBackups = Get-ChildItem $BackupDir -Filter "crm_backup_*.sql" | Where-Object {
|
||||||
$_.CreationTime -lt $cutoff
|
$_.CreationTime -lt $cutoff
|
||||||
@@ -106,7 +124,7 @@ VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationSt
|
|||||||
} catch {
|
} catch {
|
||||||
Write-Error "Backup failed: $_"
|
Write-Error "Backup failed: $_"
|
||||||
|
|
||||||
# Log failure
|
# Log failure to database for monitoring
|
||||||
$endTime = Get-Date
|
$endTime = Get-Date
|
||||||
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
|
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
|
||||||
$errorMsg = $_.ToString().Replace("'", "''")
|
$errorMsg = $_.ToString().Replace("'", "''")
|
||||||
@@ -121,5 +139,6 @@ VALUES ('full', 'failed', '$filename', '$errorMsg', $exitCode, 'failed', '$($sta
|
|||||||
|
|
||||||
exit 1
|
exit 1
|
||||||
} finally {
|
} finally {
|
||||||
|
# Clean up the password environment variable for security
|
||||||
Remove-Item "Env:PGPASSWORD" -ErrorAction SilentlyContinue
|
Remove-Item "Env:PGPASSWORD" -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,19 +28,23 @@ import { createRequire } from "node:module"
|
|||||||
const SELF = dirname(fileURLToPath(import.meta.url))
|
const SELF = dirname(fileURLToPath(import.meta.url))
|
||||||
const _require = createRequire(import.meta.url)
|
const _require = createRequire(import.meta.url)
|
||||||
const ROOT = resolve(SELF, "..")
|
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 OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434"
|
||||||
const MODEL = process.env.REPAIR_MODEL || "qwen2.5-coder:1.5b-base"
|
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 MAX_ITERATIONS = 5
|
||||||
const CONFIDENCE_THRESHOLD = 0.7
|
const CONFIDENCE_THRESHOLD = 0.7
|
||||||
const ESCALATION_TIMEOUT_MS = 30 * 60 * 1000 // 30 min
|
const ESCALATION_TIMEOUT_MS = 30 * 60 * 1000 // 30 min
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Log a message with a level-based prefix for visual scanning of output. */
|
||||||
function log(msg, level = "info") {
|
function log(msg, level = "info") {
|
||||||
const prefix = level === "error" ? "✗" : level === "warn" ? "⚠" : "✓"
|
const prefix = level === "error" ? "✗" : level === "warn" ? "⚠" : "✓"
|
||||||
console.log(` ${prefix} [repair] ${msg}`)
|
console.log(` ${prefix} [repair] ${msg}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Run `npx tsc --noEmit` and return the output (success or failure). */
|
||||||
function runTsc() {
|
function runTsc() {
|
||||||
try {
|
try {
|
||||||
const out = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT })
|
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) {
|
function parseErrors(output) {
|
||||||
const errors = []
|
const errors = []
|
||||||
// Match: src/file.ts(line,col): error TSxxxx: message
|
// Match: src/file.ts(line,col): error TSxxxx: message
|
||||||
@@ -66,6 +71,7 @@ function parseErrors(output) {
|
|||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read source file lines surrounding the error, returning a snippet with context line offset. */
|
||||||
function readContext(filePath, errorLine, contextLines = 20) {
|
function readContext(filePath, errorLine, contextLines = 20) {
|
||||||
try {
|
try {
|
||||||
const lines = readFileSync(filePath, "utf-8").split("\n")
|
const lines = readFileSync(filePath, "utf-8").split("\n")
|
||||||
@@ -83,6 +89,7 @@ function readContext(filePath, errorLine, contextLines = 20) {
|
|||||||
|
|
||||||
// ── Ollama Integration ─────────────────────────────────────────────
|
// ── Ollama Integration ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Send a repair prompt to the Ollama model and return the raw response text. */
|
||||||
async function queryOllama(prompt) {
|
async function queryOllama(prompt) {
|
||||||
const response = await fetch(`${OLLAMA_HOST}/api/generate`, {
|
const response = await fetch(`${OLLAMA_HOST}/api/generate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -99,6 +106,7 @@ async function queryOllama(prompt) {
|
|||||||
return data.response || ""
|
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) {
|
function buildRepairPrompt(filePath, errorMessage, codeContext) {
|
||||||
return `You are a TypeScript repair agent. Fix the following error.
|
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`
|
- 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) {
|
function parseFixResponse(text) {
|
||||||
try {
|
try {
|
||||||
// Extract JSON from response (it might have markdown fences)
|
// 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) {
|
function applyFix(filePath, fixSuggestion) {
|
||||||
if (!fixSuggestion || !fixSuggestion.fix) return { applied: false, reason: "No fix suggestion" }
|
if (!fixSuggestion || !fixSuggestion.fix) return { applied: false, reason: "No fix suggestion" }
|
||||||
|
|
||||||
@@ -147,8 +157,8 @@ function applyFix(filePath, fixSuggestion) {
|
|||||||
|
|
||||||
// ── Breaking Change Escalation ─────────────────────────────────────
|
// ── Breaking Change Escalation ─────────────────────────────────────
|
||||||
|
|
||||||
|
/** Heuristic check: if any fix touches imports, exports, or deletions, consider it a breaking change. */
|
||||||
function isBreakingChange(errors, fixes) {
|
function isBreakingChange(errors, fixes) {
|
||||||
// Heuristic: if fix touches import structure or exports, it's breaking
|
|
||||||
for (const fix of fixes) {
|
for (const fix of fixes) {
|
||||||
if (!fix || !fix.fix) continue
|
if (!fix || !fix.fix) continue
|
||||||
if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) {
|
if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) {
|
||||||
@@ -158,6 +168,7 @@ function isBreakingChange(errors, fixes) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Log the breaking change, wait for the escalation timeout, then auto-apply. */
|
||||||
async function escalateBreakingChange(errors, fixes) {
|
async function escalateBreakingChange(errors, fixes) {
|
||||||
const logFile = resolve(ROOT, "repair-escalation.log")
|
const logFile = resolve(ROOT, "repair-escalation.log")
|
||||||
const timestamp = new Date().toISOString()
|
const timestamp = new Date().toISOString()
|
||||||
@@ -181,6 +192,7 @@ async function escalateBreakingChange(errors, fixes) {
|
|||||||
|
|
||||||
// ── Git Auto-Commit ────────────────────────────────────────────────
|
// ── Git Auto-Commit ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Git commit all changes with a [bot]-prefixed message. Returns false on failure (non-blocking). */
|
||||||
function gitCommit(message) {
|
function gitCommit(message) {
|
||||||
try {
|
try {
|
||||||
execSync("git add -A", { stdio: "pipe", cwd: ROOT })
|
execSync("git add -A", { stdio: "pipe", cwd: ROOT })
|
||||||
@@ -195,6 +207,7 @@ function gitCommit(message) {
|
|||||||
|
|
||||||
// ── Main Repair Loop ───────────────────────────────────────────────
|
// ── 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) {
|
async function repairOnce(doCommit = false) {
|
||||||
log("Running build check...")
|
log("Running build check...")
|
||||||
const build = runTsc()
|
const build = runTsc()
|
||||||
@@ -276,6 +289,7 @@ async function repairOnce(doCommit = false) {
|
|||||||
return { fixed: remainingErrors.length === 0, errors: remainingErrors, fixes }
|
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) {
|
async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) {
|
||||||
for (let i = 0; i < maxIterations; i++) {
|
for (let i = 0; i < maxIterations; i++) {
|
||||||
log(`=== Repair iteration ${i + 1}/${maxIterations} ===`)
|
log(`=== Repair iteration ${i + 1}/${maxIterations} ===`)
|
||||||
@@ -293,8 +307,10 @@ async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) {
|
|||||||
|
|
||||||
// ── Watch Mode ─────────────────────────────────────────────────────
|
// ── Watch Mode ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Watch mode: monitor src/ for file changes and auto-repair errors. Falls back to polling if chokidar is unavailable. */
|
||||||
function watchMode() {
|
function watchMode() {
|
||||||
log("Watch mode active — monitoring src/ for changes...", "info")
|
log("Watch mode active — monitoring src/ for changes...", "info")
|
||||||
|
// Try loading chokidar; if not installed, fall back to simple interval polling
|
||||||
const chokidar = (() => {
|
const chokidar = (() => {
|
||||||
try {
|
try {
|
||||||
return _require("chokidar")
|
return _require("chokidar")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { execSync, spawn } from "node:child_process"
|
import { execSync, spawn } from "node:child_process"
|
||||||
import { platform } from "node:os"
|
import { platform } from "node:os"
|
||||||
|
|
||||||
|
/** Check if Ollama is already running by querying the OS process list. */
|
||||||
function isRunning() {
|
function isRunning() {
|
||||||
try {
|
try {
|
||||||
if (platform() === "win32") {
|
if (platform() === "win32") {
|
||||||
@@ -20,6 +21,7 @@ function isRunning() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Locate the Ollama binary via PATH, then fall back to common install directories. */
|
||||||
function findOllama() {
|
function findOllama() {
|
||||||
if (platform() === "win32") {
|
if (platform() === "win32") {
|
||||||
// Try PATH first, then common install locations
|
// Try PATH first, then common install locations
|
||||||
@@ -49,7 +51,7 @@ if (!isRunning()) {
|
|||||||
} else {
|
} else {
|
||||||
spawn(bin, ["serve"], { stdio: "ignore", detached: true }).unref()
|
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 })
|
execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
|
||||||
} else {
|
} else {
|
||||||
console.log("Ollama already running")
|
console.log("Ollama already running")
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// ── Module Generator ─────────────────────────────────────────────────
|
// ── Module Generator ─────────────────────────────────────────────────
|
||||||
// Generates missing internal modules from templates.
|
// Generates missing internal modules from templates.
|
||||||
// Supports built-in templates and custom .setup-templates/ directory.
|
// 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 fs from "node:fs";
|
||||||
import path from "node:path";
|
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 REGISTRY_PATH = path.join(SETUP_TEMPLATES_DIR, "registry.json");
|
||||||
const TEMPLATES_DIR = path.join(SETUP_TEMPLATES_DIR, "templates");
|
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 {
|
export interface TemplateRegistry {
|
||||||
[importPath: string]: {
|
[importPath: string]: {
|
||||||
template: string;
|
template: string;
|
||||||
@@ -20,6 +23,7 @@ export interface TemplateRegistry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of a single module generation attempt. */
|
||||||
export interface GenerationResult {
|
export interface GenerationResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
@@ -28,18 +32,21 @@ export interface GenerationResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Maps glob patterns (e.g. "@/hooks/*") to fallback template file names. */
|
||||||
export interface FallbackRegistry {
|
export interface FallbackRegistry {
|
||||||
[pattern: string]: string;
|
[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 } {
|
export function loadRegistry(): { templates: TemplateRegistry; fallbacks: FallbackRegistry } {
|
||||||
|
// Built-in mappings for the most commonly imported modules
|
||||||
const builtInTemplates: TemplateRegistry = {
|
const builtInTemplates: TemplateRegistry = {
|
||||||
"data/stickers": { template: "stickers.ts" },
|
"data/stickers": { template: "stickers.ts" },
|
||||||
"data/constants": { template: "constants.ts" },
|
"data/constants": { template: "constants.ts" },
|
||||||
"lib/utils": { template: "utils.ts" },
|
"lib/utils": { template: "utils.ts" },
|
||||||
"types/index": { template: "types-index.ts", params: { inferFromUsage: true } },
|
"types/index": { template: "types-index.ts", params: { inferFromUsage: true } },
|
||||||
};
|
};
|
||||||
|
// Directory-level catch-all fallbacks for unknown imports
|
||||||
const builtInFallbacks: FallbackRegistry = {
|
const builtInFallbacks: FallbackRegistry = {
|
||||||
"@/data/*": "data-generic.ts",
|
"@/data/*": "data-generic.ts",
|
||||||
"@/lib/*": "lib-generic.ts",
|
"@/lib/*": "lib-generic.ts",
|
||||||
@@ -49,6 +56,7 @@ export function loadRegistry(): { templates: TemplateRegistry; fallbacks: Fallba
|
|||||||
"@/utils/*": "utils-generic.ts",
|
"@/utils/*": "utils-generic.ts",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Merge in any custom templates from the .setup-templates directory
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(REGISTRY_PATH)) {
|
if (fs.existsSync(REGISTRY_PATH)) {
|
||||||
const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
|
const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
|
||||||
@@ -64,10 +72,10 @@ export function loadRegistry(): { templates: TemplateRegistry; fallbacks: Fallba
|
|||||||
return { templates: builtInTemplates, fallbacks: builtInFallbacks };
|
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 {
|
export function matchFallback(internalPath: string, fallbacks: FallbackRegistry): string | undefined {
|
||||||
for (const [pattern, template] of Object.entries(fallbacks)) {
|
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
|
const regexStr = "^" + pattern
|
||||||
.replace(/\//g, "\\/")
|
.replace(/\//g, "\\/")
|
||||||
.replace(/\./g, "\\.")
|
.replace(/\./g, "\\.")
|
||||||
@@ -82,7 +90,7 @@ export function matchFallback(internalPath: string, fallbacks: FallbackRegistry)
|
|||||||
return undefined;
|
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 {
|
function renderTemplate(templateContent: string, params: Record<string, unknown> = {}): string {
|
||||||
let result = templateContent;
|
let result = templateContent;
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
@@ -92,7 +100,7 @@ function renderTemplate(templateContent: string, params: Record<string, unknown>
|
|||||||
return result;
|
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 {
|
function getTemplateContent(templateName: string): string {
|
||||||
// Check custom templates first
|
// Check custom templates first
|
||||||
const customPath = path.join(TEMPLATES_DIR, templateName);
|
const customPath = path.join(TEMPLATES_DIR, templateName);
|
||||||
@@ -100,7 +108,7 @@ function getTemplateContent(templateName: string): string {
|
|||||||
return fs.readFileSync(customPath, "utf-8");
|
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> = {
|
const builtInTemplates: Record<string, string> = {
|
||||||
"stickers.ts": `// ── Sticker Packs ─────────────────────────────────────────────────
|
"stickers.ts": `// ── Sticker Packs ─────────────────────────────────────────────────
|
||||||
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
|
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
|
||||||
@@ -312,16 +320,16 @@ export interface DashboardStats {
|
|||||||
return builtInTemplates[templateName] || "";
|
return builtInTemplates[templateName] || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate a single missing module */
|
/** Generate a single missing module: resolve template, render parameters, write to src/. */
|
||||||
export function generateModule(
|
export function generateModule(
|
||||||
internalPath: string,
|
internalPath: string,
|
||||||
templates: TemplateRegistry,
|
templates: TemplateRegistry,
|
||||||
fallbacks: FallbackRegistry
|
fallbacks: FallbackRegistry
|
||||||
): GenerationResult {
|
): GenerationResult {
|
||||||
|
// Look up exact template first, then fall back to directory-level glob patterns
|
||||||
let entry = templates[internalPath];
|
let entry = templates[internalPath];
|
||||||
let templateFile = entry?.template;
|
let templateFile = entry?.template;
|
||||||
|
|
||||||
// Try fallback patterns
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
const fallbackTemplate = matchFallback(internalPath, fallbacks);
|
const fallbackTemplate = matchFallback(internalPath, fallbacks);
|
||||||
if (fallbackTemplate) {
|
if (fallbackTemplate) {
|
||||||
@@ -349,10 +357,11 @@ export function generateModule(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace template parameter placeholders with actual values
|
||||||
const rendered = renderTemplate(templateContent, entry.params || {});
|
const rendered = renderTemplate(templateContent, entry.params || {});
|
||||||
const filePath = path.join(ROOT, "src", internalPath + (internalPath.endsWith(".ts") ? "" : ".ts"));
|
const filePath = path.join(ROOT, "src", internalPath + (internalPath.endsWith(".ts") ? "" : ".ts"));
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists before writing
|
||||||
const dir = path.dirname(filePath);
|
const dir = path.dirname(filePath);
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
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[] {
|
export function generateAll(internalPaths: string[]): GenerationResult[] {
|
||||||
const { templates, fallbacks } = loadRegistry();
|
const { templates, fallbacks } = loadRegistry();
|
||||||
const results: GenerationResult[] = [];
|
const results: GenerationResult[] = [];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Parses `tsc --noEmit` or `npm run build` output to extract
|
// Parses `tsc --noEmit` or `npm run build` output to extract
|
||||||
// "Module not found: Can't resolve '@/path/to/module'" errors.
|
// "Module not found: Can't resolve '@/path/to/module'" errors.
|
||||||
// Returns actionable missing module info for the generator.
|
// Returns actionable missing module info for the generator.
|
||||||
|
// Used by both setup.mjs (self-heal) and code-repair-agent.mjs.
|
||||||
|
|
||||||
export interface MissingModule {
|
export interface MissingModule {
|
||||||
/** The import path that failed, e.g. "@/data/stickers" */
|
/** The import path that failed, e.g. "@/data/stickers" */
|
||||||
@@ -20,7 +21,7 @@ export interface MissingModule {
|
|||||||
internalPath?: string;
|
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[] {
|
export function parseMissingModules(tscOutput: string): MissingModule[] {
|
||||||
const results: 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[] {
|
export function filterGeneratable(modules: MissingModule[]): MissingModule[] {
|
||||||
return modules.filter((m) => m.isInternal && m.internalPath);
|
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[]> {
|
export function groupByInternalPath(modules: MissingModule[]): Map<string, MissingModule[]> {
|
||||||
const groups = new Map<string, MissingModule[]>();
|
const groups = new Map<string, MissingModule[]>();
|
||||||
for (const m of modules) {
|
for (const m of modules) {
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import { platform } from "node:os"
|
|||||||
|
|
||||||
const url = process.argv[2] || "http://localhost:3001/splash"
|
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))
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
// Wait for the AI server, scraper, and frontend to finish booting
|
||||||
await sleep(8000)
|
await sleep(8000)
|
||||||
try {
|
try {
|
||||||
|
// Platform-specific command to open the default browser
|
||||||
if (platform() === "win32") {
|
if (platform() === "win32") {
|
||||||
execSync(`start "" "${url}"`, { stdio: "ignore", timeout: 5000 })
|
execSync(`start "" "${url}"`, { stdio: "ignore", timeout: 5000 })
|
||||||
} else if (platform() === "darwin") {
|
} else if (platform() === "darwin") {
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
// Verifies all packages listed in package.json are installed.
|
// Verifies all packages listed in package.json are installed.
|
||||||
// Auto-runs npm install if any are missing — zero manual setup steps.
|
// 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 { resolve, dirname } from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { createRequire } from "node:module"
|
|
||||||
import { execSync } from "node:child_process"
|
import { execSync } from "node:child_process"
|
||||||
import { platform } from "node:os"
|
import { platform } from "node:os"
|
||||||
|
|
||||||
@@ -13,12 +12,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|||||||
const root = resolve(__dirname, "..")
|
const root = resolve(__dirname, "..")
|
||||||
|
|
||||||
try {
|
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 pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"))
|
||||||
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
||||||
const require = createRequire(import.meta.url)
|
|
||||||
|
|
||||||
const missing = Object.keys(allDeps).filter(name => {
|
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) {
|
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 out = execSync(`netstat -ano | findstr "LISTENING" | findstr ":${port} "`, { encoding: "utf8", timeout: 5000 })
|
||||||
const lines = out.trim().split("\n").filter(Boolean)
|
const lines = out.trim().split("\n").filter(Boolean)
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
// Parse the last column of netstat output — the PID
|
||||||
const parts = line.trim().split(/\s+/)
|
const parts = line.trim().split(/\s+/)
|
||||||
const pid = parts[parts.length - 1]
|
const pid = parts[parts.length - 1]
|
||||||
if (pid) {
|
if (pid) {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|||||||
const ROOT = resolve(__dirname, "..")
|
const ROOT = resolve(__dirname, "..")
|
||||||
|
|
||||||
// ── Load .env.local into process.env ─────────────────────────────────
|
// ── 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")
|
const envPath = resolve(ROOT, ".env.local")
|
||||||
if (existsSync(envPath)) {
|
if (existsSync(envPath)) {
|
||||||
@@ -24,9 +26,11 @@ if (existsSync(envPath)) {
|
|||||||
if (eq === -1) continue
|
if (eq === -1) continue
|
||||||
const key = trimmed.slice(0, eq).trim()
|
const key = trimmed.slice(0, eq).trim()
|
||||||
let value = trimmed.slice(eq + 1).trim()
|
let value = trimmed.slice(eq + 1).trim()
|
||||||
|
// Strip matching surrounding quotes (single or double)
|
||||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
value = value.slice(1, -1)
|
value = value.slice(1, -1)
|
||||||
}
|
}
|
||||||
|
// Don't override already-set env vars
|
||||||
if (!process.env[key]) process.env[key] = value
|
if (!process.env[key]) process.env[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +42,7 @@ if (!DATABASE_URL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Find psql ────────────────────────────────────────────────────────
|
// ── Find psql ────────────────────────────────────────────────────────
|
||||||
|
// Check PATH first, then probe common PostgreSQL v14–17 install paths.
|
||||||
|
|
||||||
function findPsql() {
|
function findPsql() {
|
||||||
try {
|
try {
|
||||||
@@ -84,8 +89,9 @@ try {
|
|||||||
|
|
||||||
// ── Migration logic ──────────────────────────────────────────────────
|
// ── Migration logic ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Run all unapplied database migrations in the order defined by run_all.sql. */
|
||||||
async function runMigrations() {
|
async function runMigrations() {
|
||||||
// 1. Create tracking table if not exists
|
// 1. Ensure the _migrations tracking table exists (idempotent)
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS _migrations (
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -94,11 +100,12 @@ async function runMigrations() {
|
|||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// 2. Determine file order from run_all.sql (authoritative)
|
// 2. Determine file order from run_all.sql (authoritative source of truth)
|
||||||
const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql")
|
const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql")
|
||||||
const runAllContent = readFileSync(runAllPath, "utf-8")
|
const runAllContent = readFileSync(runAllPath, "utf-8")
|
||||||
const fileOrder = []
|
const fileOrder = []
|
||||||
for (const line of runAllContent.split("\n")) {
|
for (const line of runAllContent.split("\n")) {
|
||||||
|
// `\i filename.sql` is the psql include directive
|
||||||
const match = line.match(/\\i\s+(\S+\.sql)/)
|
const match = line.match(/\\i\s+(\S+\.sql)/)
|
||||||
if (match) fileOrder.push(match[1])
|
if (match) fileOrder.push(match[1])
|
||||||
}
|
}
|
||||||
@@ -117,7 +124,7 @@ async function runMigrations() {
|
|||||||
appliedSet = new Set()
|
appliedSet = new Set()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Build psql command base
|
// 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`
|
const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1`
|
||||||
|
|
||||||
// 5. Run unapplied files in order.
|
// 5. Run unapplied files in order.
|
||||||
@@ -133,6 +140,7 @@ async function runMigrations() {
|
|||||||
|
|
||||||
let count = 0
|
let count = 0
|
||||||
for (const file of fileOrder) {
|
for (const file of fileOrder) {
|
||||||
|
// Skip files already recorded in the _migrations table
|
||||||
if (appliedSet.has(file)) continue
|
if (appliedSet.has(file)) continue
|
||||||
|
|
||||||
const filePath = resolve(ROOT, "database", "migrations", file)
|
const filePath = resolve(ROOT, "database", "migrations", file)
|
||||||
@@ -149,12 +157,14 @@ async function runMigrations() {
|
|||||||
maxBuffer: 10 * 1024 * 1024,
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
})
|
})
|
||||||
// psql outputs notices/warnings to stderr even on success
|
// psql outputs notices/warnings to stderr even on success — capture for diagnostics
|
||||||
stderr = result.stderr || ""
|
stderr = result.stderr || ""
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Distinguish between "already applied" (harmless) and genuine failures
|
||||||
const msg = err.stderr || err.stdout || err.message
|
const msg = err.stderr || err.stdout || err.message
|
||||||
const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p))
|
const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p))
|
||||||
if (isAlreadyApplied) {
|
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])
|
await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file])
|
||||||
console.log(` ~ Already applied: ${file}`)
|
console.log(` ~ Already applied: ${file}`)
|
||||||
count++
|
count++
|
||||||
@@ -165,6 +175,7 @@ async function runMigrations() {
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record successful migration
|
||||||
await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file])
|
await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file])
|
||||||
console.log(` ✓ Applied: ${file}`)
|
console.log(` ✓ Applied: ${file}`)
|
||||||
count++
|
count++
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { execSync, spawn } from "node:child_process"
|
|||||||
import { platform } from "node:os"
|
import { platform } from "node:os"
|
||||||
import { statSync } from "node:fs"
|
import { statSync } from "node:fs"
|
||||||
|
|
||||||
|
/** Locate the Python 3 executable. Checks common install paths first, then the system PATH. */
|
||||||
function detectPython() {
|
function detectPython() {
|
||||||
|
// Pre-check common Windows install directories to avoid PATH lookup
|
||||||
const commonPaths = [
|
const commonPaths = [
|
||||||
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python313\\python.exe`,
|
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python313\\python.exe`,
|
||||||
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python312\\python.exe`,
|
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python312\\python.exe`,
|
||||||
@@ -21,6 +23,7 @@ function detectPython() {
|
|||||||
return p
|
return p
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
// Fall back to PATH resolution (prefer python3 on Unix)
|
||||||
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
|
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
|
||||||
for (const cmd of candidates) {
|
for (const cmd of candidates) {
|
||||||
try {
|
try {
|
||||||
@@ -42,6 +45,6 @@ if (!script) {
|
|||||||
process.exit(1)
|
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" })
|
const proc = spawn(PYTHON, [script, ...args], { stdio: "inherit" })
|
||||||
proc.on("exit", (code) => process.exit(code ?? 1))
|
proc.on("exit", (code) => process.exit(code ?? 1))
|
||||||
|
|||||||
+22
-67
@@ -15,12 +15,14 @@ import { platform } from "node:os"
|
|||||||
import { resolve, dirname } from "node:path"
|
import { resolve, dirname } from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
// Shell command separator differs per platform (Windows uses &, POSIX uses ;)
|
||||||
const SEP = platform() === "win32" ? "&" : ";"
|
const SEP = platform() === "win32" ? "&" : ";"
|
||||||
const SELF = dirname(fileURLToPath(import.meta.url))
|
const SELF = dirname(fileURLToPath(import.meta.url))
|
||||||
const ROOT = resolve(SELF, "..")
|
const ROOT = resolve(SELF, "..")
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Try known Python command names until one succeeds, then exit if none found. */
|
||||||
function detectPython() {
|
function detectPython() {
|
||||||
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
|
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
|
||||||
for (const cmd of candidates) {
|
for (const cmd of candidates) {
|
||||||
@@ -33,6 +35,7 @@ function detectPython() {
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Detect pip; fall back to `python -m pip` if no standalone pip binary exists. */
|
||||||
function detectPip(python) {
|
function detectPip(python) {
|
||||||
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
|
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
|
||||||
for (const cmd of candidates) {
|
for (const cmd of candidates) {
|
||||||
@@ -44,6 +47,7 @@ function detectPip(python) {
|
|||||||
return `${python} -m pip`
|
return `${python} -m pip`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Run a shell command with label output. Exits on failure unless opts.optional is set. */
|
||||||
function run(cmd, label, opts = {}) {
|
function run(cmd, label, opts = {}) {
|
||||||
console.log(`\n── ${label} ──`)
|
console.log(`\n── ${label} ──`)
|
||||||
try {
|
try {
|
||||||
@@ -60,11 +64,15 @@ function run(cmd, label, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Self-Healing Module Registry ───────────────────────────────────
|
// ── 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() {
|
function loadRegistry() {
|
||||||
const registryPath = resolve(ROOT, ".setup-templates", "registry.json")
|
const registryPath = resolve(ROOT, ".setup-templates", "registry.json")
|
||||||
const templatesDir = resolve(ROOT, ".setup-templates", "templates")
|
const templatesDir = resolve(ROOT, ".setup-templates", "templates")
|
||||||
|
|
||||||
|
// Hard-coded templates for commonly imported modules
|
||||||
const builtInTemplates = {
|
const builtInTemplates = {
|
||||||
"data/stickers": { template: "stickers.ts" },
|
"data/stickers": { template: "stickers.ts" },
|
||||||
"data/constants": { template: "constants.ts" },
|
"data/constants": { template: "constants.ts" },
|
||||||
@@ -75,6 +83,7 @@ function loadRegistry() {
|
|||||||
"hooks/use-local-storage": { template: "hooks/use-local-storage.ts" },
|
"hooks/use-local-storage": { template: "hooks/use-local-storage.ts" },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Catch-all fallbacks for entire directory prefixes
|
||||||
const builtInFallbacks = {
|
const builtInFallbacks = {
|
||||||
"@/data/*": "data-generic.ts",
|
"@/data/*": "data-generic.ts",
|
||||||
"@/lib/*": "lib-generic.ts",
|
"@/lib/*": "lib-generic.ts",
|
||||||
@@ -87,6 +96,7 @@ function loadRegistry() {
|
|||||||
let templates = { ...builtInTemplates }
|
let templates = { ...builtInTemplates }
|
||||||
let fallbacks = { ...builtInFallbacks }
|
let fallbacks = { ...builtInFallbacks }
|
||||||
|
|
||||||
|
// Merge custom templates from .setup-templates/registry.json (if it exists)
|
||||||
try {
|
try {
|
||||||
if (existsSync(registryPath)) {
|
if (existsSync(registryPath)) {
|
||||||
const custom = JSON.parse(readFileSync(registryPath, "utf-8"))
|
const custom = JSON.parse(readFileSync(registryPath, "utf-8"))
|
||||||
@@ -97,6 +107,7 @@ function loadRegistry() {
|
|||||||
|
|
||||||
const templateCache = {}
|
const templateCache = {}
|
||||||
|
|
||||||
|
/** Read a template file, checking the custom templates dir first, then hooks subdirectory. */
|
||||||
function getTemplate(templateName) {
|
function getTemplate(templateName) {
|
||||||
if (templateCache[templateName]) return templateCache[templateName]
|
if (templateCache[templateName]) return templateCache[templateName]
|
||||||
const candidatePaths = [
|
const candidatePaths = [
|
||||||
@@ -113,6 +124,7 @@ function loadRegistry() {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convert a glob pattern (e.g. @/hooks/*) to a regex and test against the internal path. */
|
||||||
function matchFallback(internalPath) {
|
function matchFallback(internalPath) {
|
||||||
for (const [pattern, templateFile] of Object.entries(fallbacks)) {
|
for (const [pattern, templateFile] of Object.entries(fallbacks)) {
|
||||||
const regexStr = "^" + pattern
|
const regexStr = "^" + pattern
|
||||||
@@ -125,6 +137,7 @@ function loadRegistry() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generate (or regenerate) a single module file from its template. */
|
||||||
function generateModule(internalPath) {
|
function generateModule(internalPath) {
|
||||||
let templateFile = templates[internalPath]?.template
|
let templateFile = templates[internalPath]?.template
|
||||||
if (!templateFile) templateFile = matchFallback(internalPath)
|
if (!templateFile) templateFile = matchFallback(internalPath)
|
||||||
@@ -143,6 +156,7 @@ function loadRegistry() {
|
|||||||
return { generateModule }
|
return { generateModule }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse `tsc --noEmit` or `npm run build` output for missing-module errors, returning unique import paths. */
|
||||||
function parseMissingModules(buildOutput) {
|
function parseMissingModules(buildOutput) {
|
||||||
const results = []
|
const results = []
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
@@ -177,23 +191,22 @@ function parseMissingModules(buildOutput) {
|
|||||||
|
|
||||||
// ── Main ───────────────────────────────────────────────────────────
|
// ── Main ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const args = process.argv.slice(2)
|
// Detect Python and pip executables before running any steps
|
||||||
const doSelfHeal = args.includes("--self-heal") || args.includes("-s")
|
|
||||||
const PY = detectPython()
|
const PY = detectPython()
|
||||||
const PIP = detectPip(PY)
|
const PIP = detectPip(PY)
|
||||||
|
|
||||||
console.log("=== CoastIT CRM Setup ===\n")
|
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")
|
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")
|
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")
|
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"))) {
|
if (!existsSync(resolve(ROOT, ".env.local"))) {
|
||||||
console.log("\n── Creating .env.local ──")
|
console.log("\n── Creating .env.local ──")
|
||||||
copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local"))
|
copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local"))
|
||||||
@@ -202,69 +215,11 @@ if (!existsSync(resolve(ROOT, ".env.local"))) {
|
|||||||
console.log("\n── .env.local already exists, skipping ──")
|
console.log("\n── .env.local already exists, skipping ──")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Database migrations (auto-applies on fresh install)
|
// Step 5 — Print post-setup instructions
|
||||||
console.log("\n── Running Database Migrations ──")
|
|
||||||
try {
|
|
||||||
execSync("node scripts/run-migrations.mjs", { stdio: "inherit", timeout: 30000, cwd: ROOT })
|
|
||||||
console.log(" ✓ Migrations complete")
|
|
||||||
} catch {
|
|
||||||
console.log(" ~ Database not available — run migrations later with: npm run dev")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Final summary
|
|
||||||
console.log("\n── Final Steps ──")
|
console.log("\n── Final Steps ──")
|
||||||
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
|
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(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
|
||||||
console.log(" 3. Edit .env.local with your settings")
|
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! ===")
|
console.log("\n=== Setup complete! ===")
|
||||||
|
|||||||
+68
-2
@@ -1,20 +1,44 @@
|
|||||||
|
// ── WebRTC Signaling Server ─────────────────────────────────────────
|
||||||
|
// Provides real-time signaling for the CRM chat system using Socket.IO:
|
||||||
|
// - JWT-based authentication middleware
|
||||||
|
// - Online user presence tracking (online/offline)
|
||||||
|
// - Peer-to-peer call signaling (offer/answer/ICE candidates)
|
||||||
|
// - Multi-participant room-based WebRTC (join/leave/relay)
|
||||||
|
// - Message deletion broadcast to conversation participants
|
||||||
|
//
|
||||||
|
// Clients authenticate via socket handshake auth token; unauthenticated
|
||||||
|
// users can still join rooms as anonymous participants.
|
||||||
|
|
||||||
import { createServer } from "http"
|
import { createServer } from "http"
|
||||||
import { Server } from "socket.io"
|
import { Server } from "socket.io"
|
||||||
import { SignJWT, jwtVerify } from "jose"
|
import { SignJWT, jwtVerify } from "jose"
|
||||||
import pg from "pg"
|
import pg from "pg"
|
||||||
|
|
||||||
|
// ── Configuration ─────────────────────────────────────────────────
|
||||||
const PORT = process.env.SIGNALING_PORT || 3007
|
const PORT = process.env.SIGNALING_PORT || 3007
|
||||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026")
|
const rawSecret = process.env.JWT_SECRET
|
||||||
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm"
|
if (!rawSecret) throw new Error("JWT_SECRET environment variable is required")
|
||||||
|
const JWT_SECRET = new TextEncoder().encode(rawSecret)
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL
|
||||||
|
if (!DATABASE_URL) throw new Error("DATABASE_URL environment variable is required")
|
||||||
|
|
||||||
|
// ── Database pool ─────────────────────────────────────────────────
|
||||||
const pool = new pg.Pool({ connectionString: DATABASE_URL })
|
const pool = new pg.Pool({ connectionString: DATABASE_URL })
|
||||||
|
|
||||||
|
// ── HTTP + Socket.IO server ───────────────────────────────────────
|
||||||
const httpServer = createServer()
|
const httpServer = createServer()
|
||||||
const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } })
|
const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } })
|
||||||
|
|
||||||
|
// ── In-memory state ───────────────────────────────────────────────
|
||||||
|
// onlineUsers maps userId -> socketId for presence tracking.
|
||||||
|
// rooms maps roomId -> array of { socketId, id, label } participants.
|
||||||
const onlineUsers = new Map()
|
const onlineUsers = new Map()
|
||||||
const rooms = new Map()
|
const rooms = new Map()
|
||||||
|
|
||||||
|
// ── Authentication middleware ─────────────────────────────────────
|
||||||
|
// Verifies the JWT token from handshake auth. If valid, attaches
|
||||||
|
// userId and role to the socket's data bag. Unauthenticated sockets
|
||||||
|
// proceed with null user info.
|
||||||
io.use((socket, next) => {
|
io.use((socket, next) => {
|
||||||
const token = socket.handshake.auth?.token
|
const token = socket.handshake.auth?.token
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -36,15 +60,25 @@ io.use((socket, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Connection handler ────────────────────────────────────────────
|
||||||
|
// Registers authenticated users as online, then wires up all
|
||||||
|
// per-socket event listeners for signaling and presence.
|
||||||
io.on("connection", (socket) => {
|
io.on("connection", (socket) => {
|
||||||
const userId = socket.data.userId
|
const userId = socket.data.userId
|
||||||
|
|
||||||
|
// Mark the user online and broadcast to other clients
|
||||||
if (userId) {
|
if (userId) {
|
||||||
onlineUsers.set(userId, socket.id)
|
onlineUsers.set(userId, socket.id)
|
||||||
socket.broadcast.emit("user:online", { userId })
|
socket.broadcast.emit("user:online", { userId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Authenticated event handlers ──────────────────────────────
|
||||||
|
// Only users with a valid JWT token may use these features.
|
||||||
if (userId) {
|
if (userId) {
|
||||||
|
|
||||||
|
// user:lookup — looks up a user by phone number in the database.
|
||||||
|
// Fires when the client searches for a contact to start a call.
|
||||||
|
// Returns { found, user } or { found: false }.
|
||||||
socket.on("user:lookup", async ({ phone }, callback) => {
|
socket.on("user:lookup", async ({ phone }, callback) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@@ -76,6 +110,9 @@ io.on("connection", (socket) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// call:offer — relays an SDP offer to the target user.
|
||||||
|
// Fires when the caller initiates a WebRTC call.
|
||||||
|
// callerInfo is fetched from the DB if not supplied.
|
||||||
socket.on("call:offer", async ({ to, sdp, callerInfo }) => {
|
socket.on("call:offer", async ({ to, sdp, callerInfo }) => {
|
||||||
const targetSocketId = onlineUsers.get(to)
|
const targetSocketId = onlineUsers.get(to)
|
||||||
if (targetSocketId) {
|
if (targetSocketId) {
|
||||||
@@ -109,6 +146,8 @@ io.on("connection", (socket) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// call:answer — relays an SDP answer back to the caller.
|
||||||
|
// Fires when the callee accepts the incoming call.
|
||||||
socket.on("call:answer", ({ to, sdp }) => {
|
socket.on("call:answer", ({ to, sdp }) => {
|
||||||
const targetSocketId = onlineUsers.get(to)
|
const targetSocketId = onlineUsers.get(to)
|
||||||
if (targetSocketId) {
|
if (targetSocketId) {
|
||||||
@@ -116,6 +155,8 @@ io.on("connection", (socket) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// call:ice-candidate — relays ICE candidates between peers.
|
||||||
|
// Fires during connection negotiation for NAT traversal.
|
||||||
socket.on("call:ice-candidate", ({ to, candidate }) => {
|
socket.on("call:ice-candidate", ({ to, candidate }) => {
|
||||||
const targetSocketId = onlineUsers.get(to)
|
const targetSocketId = onlineUsers.get(to)
|
||||||
if (targetSocketId) {
|
if (targetSocketId) {
|
||||||
@@ -123,6 +164,7 @@ io.on("connection", (socket) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// call:end — notifies the remote peer that the call ended.
|
||||||
socket.on("call:end", ({ to }) => {
|
socket.on("call:end", ({ to }) => {
|
||||||
const targetSocketId = onlineUsers.get(to)
|
const targetSocketId = onlineUsers.get(to)
|
||||||
if (targetSocketId) {
|
if (targetSocketId) {
|
||||||
@@ -130,6 +172,7 @@ io.on("connection", (socket) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// call:reject — tells the caller the callee rejected the call.
|
||||||
socket.on("call:reject", ({ to }) => {
|
socket.on("call:reject", ({ to }) => {
|
||||||
const targetSocketId = onlineUsers.get(to)
|
const targetSocketId = onlineUsers.get(to)
|
||||||
if (targetSocketId) {
|
if (targetSocketId) {
|
||||||
@@ -137,6 +180,7 @@ io.on("connection", (socket) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// call:busy — tells the caller the callee is on another call.
|
||||||
socket.on("call:busy", ({ to }) => {
|
socket.on("call:busy", ({ to }) => {
|
||||||
const targetSocketId = onlineUsers.get(to)
|
const targetSocketId = onlineUsers.get(to)
|
||||||
if (targetSocketId) {
|
if (targetSocketId) {
|
||||||
@@ -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 }) => {
|
socket.on("message:deleted", async ({ conversationId, messageId, senderId }) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
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 }) => {
|
socket.on("room:join", ({ roomId, displayName }) => {
|
||||||
const participantId = userId || `anon-${socket.id}`
|
const participantId = userId || `anon-${socket.id}`
|
||||||
const participantLabel = displayName || participantId
|
const participantLabel = displayName || participantId
|
||||||
@@ -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.on("room:offer", ({ roomId, sdp }) => {
|
||||||
socket.to(roomId).emit("room:offer", { sdp })
|
socket.to(roomId).emit("room:offer", { sdp })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// room:answer — relays an SDP answer to all other room participants.
|
||||||
socket.on("room:answer", ({ roomId, sdp }) => {
|
socket.on("room:answer", ({ roomId, sdp }) => {
|
||||||
socket.to(roomId).emit("room:answer", { sdp })
|
socket.to(roomId).emit("room:answer", { sdp })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// room:ice-candidate — relays ICE candidates to all other room participants.
|
||||||
socket.on("room:ice-candidate", ({ roomId, candidate }) => {
|
socket.on("room:ice-candidate", ({ roomId, candidate }) => {
|
||||||
socket.to(roomId).emit("room:ice-candidate", { candidate })
|
socket.to(roomId).emit("room:ice-candidate", { candidate })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// room:leave — removes the socket from a room. Cleans up the
|
||||||
|
// in-memory room map and notifies remaining participants. Rooms
|
||||||
|
// are fully deleted when empty.
|
||||||
socket.on("room:leave", ({ roomId }) => {
|
socket.on("room:leave", ({ roomId }) => {
|
||||||
socket.leave(roomId)
|
socket.leave(roomId)
|
||||||
const participants = rooms.get(roomId)
|
const participants = rooms.get(roomId)
|
||||||
@@ -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", () => {
|
socket.on("disconnect", () => {
|
||||||
for (const [roomId, participants] of rooms) {
|
for (const [roomId, participants] of rooms) {
|
||||||
const idx = participants.findIndex(p => p.socketId === socket.id)
|
const idx = participants.findIndex(p => p.socketId === socket.id)
|
||||||
@@ -235,6 +300,7 @@ io.on("connection", (socket) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────────────
|
||||||
httpServer.listen(PORT, () => {
|
httpServer.listen(PORT, () => {
|
||||||
console.log(`[signaling] server running on port ${PORT}`)
|
console.log(`[signaling] server running on port ${PORT}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from "react"
|
import { useState, useCallback, useRef } from "react"
|
||||||
@@ -5,6 +18,16 @@ import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat"
|
|||||||
import { JobSelector } from "@/components/ai/job-selector"
|
import { JobSelector } from "@/components/ai/job-selector"
|
||||||
import { Bot, ChevronRight } from "lucide-react"
|
import { Bot, ChevronRight } from "lucide-react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AIAssistantPage
|
||||||
|
* ───────────────
|
||||||
|
* Manages the AI chat + job search workflow.
|
||||||
|
* Selected job is shown in the sidebar; search
|
||||||
|
* triggers a Facebook scrape via an external
|
||||||
|
* scraper service with a 6-minute timeout.
|
||||||
|
*
|
||||||
|
* @returns AI assistant layout.
|
||||||
|
*/
|
||||||
export default function AIAssistantPage() {
|
export default function AIAssistantPage() {
|
||||||
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
|
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
|
||||||
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
|
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
|
||||||
@@ -15,26 +38,35 @@ export default function AIAssistantPage() {
|
|||||||
setSelectedJob(job)
|
setSelectedJob(job)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Facebook lead scraper handler ──
|
||||||
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
|
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
|
||||||
setSearching(true)
|
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...`)
|
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 controller = new AbortController()
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 360000)
|
const timeoutId = setTimeout(() => controller.abort(), 360000)
|
||||||
const statusId = setTimeout(() => {
|
const statusId = setTimeout(() => {
|
||||||
aiChatRef.current?.addAssistantMessage("⏳ Still searching Facebook (this can take up to 5 minutes)...")
|
aiChatRef.current?.addAssistantMessage("⏳ Still searching Facebook (this can take up to 5 minutes)...")
|
||||||
}, 45000)
|
}, 45000)
|
||||||
|
|
||||||
|
const scrapBase = process.env.NEXT_PUBLIC_SCRAPER_URL || "http://localhost:3008"
|
||||||
|
|
||||||
try {
|
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(timeoutId)
|
||||||
clearTimeout(statusId)
|
clearTimeout(statusId)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (data.success && data.leads?.length > 0) {
|
if (data.success && data.leads?.length > 0) {
|
||||||
const leadsText = data.leads.map((lead: any, i: number) =>
|
// Format scraped leads as a markdown list for the chat
|
||||||
`**${i + 1}.** ${lead.author || "Unknown"}\n> ${(lead.content || "").slice(0, 300)}\n> 🔗 ${lead.url || "(no link available)"}`
|
const leadLines = data.leads
|
||||||
).join("\n\n")
|
.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}`)
|
aiChatRef.current?.addAssistantMessage(`✅ Found **${data.leads.length}** leads:\n\n${leadsText}`)
|
||||||
} else {
|
} else {
|
||||||
const reason = data.error || data.flag_reason || "No leads found this time"
|
const reason = data.error || data.flag_reason || "No leads found this time"
|
||||||
@@ -56,6 +88,7 @@ export default function AIAssistantPage() {
|
|||||||
aiChatRef.current?.fillInput(prompt)
|
aiChatRef.current?.fillInput(prompt)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Keep track of last 3 sent prompts for quick reuse
|
||||||
const handleMessageSent = useCallback((msg: string) => {
|
const handleMessageSent = useCallback((msg: string) => {
|
||||||
setRecentPrompts((prev) => {
|
setRecentPrompts((prev) => {
|
||||||
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
|
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
|
||||||
@@ -65,7 +98,9 @@ export default function AIAssistantPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
|
<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">
|
<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="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 justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -77,6 +112,7 @@ export default function AIAssistantPage() {
|
|||||||
<p className="text-muted-foreground text-xs mt-0.5">Powered by local AI</p>
|
<p className="text-muted-foreground text-xs mt-0.5">Powered by local AI</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Status indicator */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
|
||||||
@@ -91,6 +127,8 @@ export default function AIAssistantPage() {
|
|||||||
</div>
|
</div>
|
||||||
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
|
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
|
||||||
</div>
|
</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 className="w-[300px] flex-none bg-sidebar border border-foreground transition-colors p-5 overflow-y-auto h-full flex flex-col">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
@@ -98,6 +136,7 @@ export default function AIAssistantPage() {
|
|||||||
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
|
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
|
||||||
</div>
|
</div>
|
||||||
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
|
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
|
||||||
|
{/* Selected job details card */}
|
||||||
{selectedJob && (
|
{selectedJob && (
|
||||||
<div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2">
|
<div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">{selectedJob.job_title}</h4>
|
<h4 className="text-sm font-semibold text-foreground">{selectedJob.job_title}</h4>
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, Component, type ReactNode } from "react"
|
import { useState, useEffect, useCallback, Component, type ReactNode } from "react"
|
||||||
@@ -25,16 +38,19 @@ import {
|
|||||||
ArrowUpRight, Zap, StickyNote, Globe,
|
ArrowUpRight, Zap, StickyNote, Globe,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
|
// ── Event type icon mapping ──
|
||||||
const EVENT_ICONS: Record<EventType, React.ElementType> = {
|
const EVENT_ICONS: Record<EventType, React.ElementType> = {
|
||||||
call: Phone,
|
call: Phone,
|
||||||
follow_up: Clock,
|
follow_up: Clock,
|
||||||
website_creation: Globe,
|
website_creation: Globe,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** CSS variable name for an event type (e.g. `--event-call`). */
|
||||||
function eventVars(type: EventType) {
|
function eventVars(type: EventType) {
|
||||||
return `--event-${type}`
|
return `--event-${type}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Background + text + border style derived from the event type's CSS variable. */
|
||||||
function eventBgStyle(type: EventType): React.CSSProperties {
|
function eventBgStyle(type: EventType): React.CSSProperties {
|
||||||
const v = eventVars(type)
|
const v = eventVars(type)
|
||||||
return {
|
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 {
|
function eventDotStyle(type: EventType): React.CSSProperties {
|
||||||
return { backgroundColor: `hsl(var(--event-${type}))` }
|
return { backgroundColor: `hsl(var(--event-${type}))` }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the number of days in a given month (0-indexed). */
|
||||||
function getDaysInMonth(year: number, month: number) {
|
function getDaysInMonth(year: number, month: number) {
|
||||||
return new Date(year, month + 1, 0).getDate()
|
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) {
|
function getFirstDayOfMonth(year: number, month: number) {
|
||||||
return new Date(year, month, 1).getDay()
|
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" })
|
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() {
|
export default function CalendarPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
@@ -286,6 +316,7 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Update event status (complete, cancel, reopen) ──
|
||||||
const updateEventStatus = async (event: CalendarEvent, newStatus: string) => {
|
const updateEventStatus = async (event: CalendarEvent, newStatus: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/events/${event.id}`, {
|
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")
|
if (!res.ok) throw new Error("Failed to update")
|
||||||
const data = await res.json()
|
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))
|
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}`)
|
toast.success(`Event ${newStatus}`)
|
||||||
setDetailEvent(null)
|
setDetailEvent(null)
|
||||||
@@ -303,6 +335,7 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Save participant notes for an event ──
|
||||||
const saveNotes = async (event: CalendarEvent) => {
|
const saveNotes = async (event: CalendarEvent) => {
|
||||||
if (notesText === (event.participantNotes || "")) { setEditNotes(false); return }
|
if (notesText === (event.participantNotes || "")) { setEditNotes(false); return }
|
||||||
setNotesSaving(true)
|
setNotesSaving(true)
|
||||||
@@ -315,6 +348,7 @@ export default function CalendarPage() {
|
|||||||
if (!res.ok) throw new Error("Failed to save")
|
if (!res.ok) throw new Error("Failed to save")
|
||||||
const data = await res.json()
|
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))
|
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) {
|
if (detailEvent && detailEvent.id === event.id) {
|
||||||
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
|
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
|
||||||
}
|
}
|
||||||
@@ -327,6 +361,7 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Delete an event ──
|
||||||
const deleteEvent = async (event: CalendarEvent) => {
|
const deleteEvent = async (event: CalendarEvent) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/events/${event.id}`, { method: "DELETE" })
|
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)[] = []
|
const calendarDays: (number | null)[] = []
|
||||||
for (let i = 0; i < firstDay; i++) calendarDays.push(null)
|
for (let i = 0; i < firstDay; i++) calendarDays.push(null)
|
||||||
for (let d = 1; d <= daysInMonth; d++) calendarDays.push(d)
|
for (let d = 1; d <= daysInMonth; d++) calendarDays.push(d)
|
||||||
@@ -348,7 +384,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-4rem)] bg-gradient-to-b from-background via-background to-muted/20">
|
<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 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-4">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -377,7 +413,7 @@ export default function CalendarPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<div className="flex-1 flex overflow-hidden p-4 lg:p-6 gap-4 lg:gap-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
@@ -391,9 +427,9 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Left: Calendar Grid */}
|
{/* ── CALENDAR GRID (left column) ── */}
|
||||||
<div className="flex-1 flex flex-col gap-4 min-w-0 overflow-hidden">
|
<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-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="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" />
|
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||||
@@ -412,10 +448,10 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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="flex-1 overflow-auto rounded-xl border bg-card shadow-sm">
|
||||||
<div className="min-w-[600px]">
|
<div className="min-w-[600px]">
|
||||||
{/* Day headers */}
|
{/* Day-of-week column headers (Sun–Sat) */}
|
||||||
<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">
|
<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) => (
|
{DAYS.map((d, i) => (
|
||||||
<div key={d} className={cn(
|
<div key={d} className={cn(
|
||||||
@@ -428,7 +464,7 @@ export default function CalendarPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day cells */}
|
{/* Calendar day cells */}
|
||||||
<div className="grid grid-cols-7">
|
<div className="grid grid-cols-7">
|
||||||
{calendarDays.map((day, i) => {
|
{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" />
|
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>
|
||||||
</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="w-72 lg:w-80 shrink-0 flex flex-col gap-4 overflow-y-auto">
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h2 className="text-sm font-bold flex items-center gap-2 tracking-tight">
|
<h2 className="text-sm font-bold flex items-center gap-2 tracking-tight">
|
||||||
@@ -612,7 +648,7 @@ export default function CalendarPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
{/* ── CREATE / EDIT EVENT DIALOG ── */}
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -768,7 +804,7 @@ export default function CalendarPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Event Detail Dialog */}
|
{/* ── EVENT DETAIL DIALOG ── */}
|
||||||
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
|
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
|
||||||
{detailEvent && (
|
{detailEvent && (
|
||||||
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Chats Page (/(dashboard)/chats)
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Route: /chats
|
||||||
|
// Purpose: Full-featured real-time messaging with
|
||||||
|
// voice notes, file attachments, GIFs, stickers,
|
||||||
|
// message editing/deletion/forwarding, read
|
||||||
|
// receipts, emoji picker, inline image/avatar
|
||||||
|
// previews, voice calls, and event scheduling.
|
||||||
|
// Layout: Resizable two-panel layout — left:
|
||||||
|
// conversation list; right: chat area (header,
|
||||||
|
// messages, input bar).
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||||
@@ -20,6 +34,7 @@ import {
|
|||||||
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
||||||
|
import { sanitizeSvg } from "@/lib/sanitize"
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
DialogFooter, DialogClose,
|
DialogFooter, DialogClose,
|
||||||
@@ -36,10 +51,24 @@ import { io, Socket } from "socket.io-client"
|
|||||||
import VoiceCallModal from "@/components/chats/voice-call-modal"
|
import VoiceCallModal from "@/components/chats/voice-call-modal"
|
||||||
import MediaPicker from "@/components/chats/media-picker"
|
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 activeAudioRef: { current: HTMLAudioElement | null } = { current: null }
|
||||||
const previewAudioRef: { current: HTMLAudioElement | null } = { current: null }
|
const previewAudioRef: { current: HTMLAudioElement | null } = { current: null }
|
||||||
const previewAnimRef: { current: number } = { current: 0 }
|
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 }) {
|
function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; initialDuration: number; isOwn?: boolean }) {
|
||||||
const [playing, setPlaying] = useState(false)
|
const [playing, setPlaying] = useState(false)
|
||||||
const [current, setCurrent] = useState(0)
|
const [current, setCurrent] = useState(0)
|
||||||
@@ -48,6 +77,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init
|
|||||||
const animRef = useRef<number>(0)
|
const animRef = useRef<number>(0)
|
||||||
const progRef = useRef<HTMLInputElement>(null)
|
const progRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Load actual duration from metadata (fallback to initialDuration)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current
|
const audio = audioRef.current
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
@@ -58,6 +88,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init
|
|||||||
return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("ended", onEnded) }
|
return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("ended", onEnded) }
|
||||||
}, [src])
|
}, [src])
|
||||||
|
|
||||||
|
// Animation frame loop — updates current time while playing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playing) { cancelAnimationFrame(animRef.current); return }
|
if (!playing) { cancelAnimationFrame(animRef.current); return }
|
||||||
const tick = () => { if (audioRef.current) setCurrent(audioRef.current.currentTime); animRef.current = requestAnimationFrame(tick) }
|
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 (!audioRef.current) return
|
||||||
if (playing) { audioRef.current.pause(); setPlaying(false) }
|
if (playing) { audioRef.current.pause(); setPlaying(false) }
|
||||||
else {
|
else {
|
||||||
|
// Pause any other playing voice note
|
||||||
if (activeAudioRef.current && activeAudioRef.current !== audioRef.current) { activeAudioRef.current.pause() }
|
if (activeAudioRef.current && activeAudioRef.current !== audioRef.current) { activeAudioRef.current.pause() }
|
||||||
activeAudioRef.current = audioRef.current
|
activeAudioRef.current = audioRef.current
|
||||||
audioRef.current.play(); setPlaying(true)
|
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 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")}` }
|
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 barCount = 28
|
||||||
const waveform = Array.from({ length: barCount }, (_, i) => {
|
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
|
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 MAX_CACHED_CONVERSATIONS = 5
|
||||||
const otherParticipant = (conv: any) => conv.otherUser
|
const otherParticipant = (conv: any) => conv.otherUser
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// ChatsPage — Main Chat Application Component
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Full-featured chat interface. Manages:
|
||||||
|
* - Conversation list with search / create
|
||||||
|
* - Real-time message polling (4 s) + Socket.io
|
||||||
|
* - Voice recording, file attachments, GIF/stickers
|
||||||
|
* - Message edit, delete, forward, reply
|
||||||
|
* - Resizable panel, read receipts, emoji picker
|
||||||
|
* - Voice call modal + event scheduling
|
||||||
|
*
|
||||||
|
* @returns Chat layout or null if no user.
|
||||||
|
*/
|
||||||
export default function ChatsPage() {
|
export default function ChatsPage() {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
@@ -186,8 +234,10 @@ export default function ChatsPage() {
|
|||||||
const socketRef = useRef<Socket | null>(null)
|
const socketRef = useRef<Socket | null>(null)
|
||||||
const isDeletingRef = useRef(false)
|
const isDeletingRef = useRef(false)
|
||||||
|
|
||||||
|
/** Check if a string consists only of emoji characters. */
|
||||||
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
|
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
|
||||||
|
|
||||||
|
/** Parse message content for preview display in conversation list. */
|
||||||
const formatPreviewContent = (content: string) => {
|
const formatPreviewContent = (content: string) => {
|
||||||
if (!content?.startsWith("{")) return content
|
if (!content?.startsWith("{")) return content
|
||||||
try {
|
try {
|
||||||
@@ -199,6 +249,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Auto-resize the textarea based on its scroll height. */
|
||||||
const autoResizeTextarea = useCallback(() => {
|
const autoResizeTextarea = useCallback(() => {
|
||||||
const ta = textareaRef.current
|
const ta = textareaRef.current
|
||||||
if (!ta) return
|
if (!ta) return
|
||||||
@@ -208,6 +259,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
|
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|
||||||
|
// ── Memoized derived data ──
|
||||||
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
|
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
|
||||||
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
|
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
|
||||||
const filteredConversations = useMemo(() => {
|
const filteredConversations = useMemo(() => {
|
||||||
@@ -793,11 +845,12 @@ const formatPreviewContent = (content: string) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100dvh-4rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
<div className="flex h-[calc(100dvh-4rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
||||||
{/* Conversations list - left panel */}
|
{/* ── CONVERSATIONS LIST (left panel) ── */}
|
||||||
<div
|
<div
|
||||||
className="flex flex-col border-r shrink-0 overflow-hidden"
|
className="flex flex-col border-r shrink-0 overflow-hidden"
|
||||||
style={{ width: panelWidth }}
|
style={{ width: panelWidth }}
|
||||||
>
|
>
|
||||||
|
{/* Search header */}
|
||||||
<div className="p-4 border-b space-y-3">
|
<div className="p-4 border-b space-y-3">
|
||||||
<h2 className="text-lg font-semibold">Chats</h2>
|
<h2 className="text-lg font-semibold">Chats</h2>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -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" />
|
<Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search conversations..." className="h-9 pl-9" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Scrollable conversation list */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{filteredConversations.map((conv) => {
|
{filteredConversations.map((conv) => {
|
||||||
const person = otherParticipant(conv)
|
const person = otherParticipant(conv)
|
||||||
@@ -896,7 +950,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resize handle */}
|
{/* ── RESIZE HANDLE ── */}
|
||||||
<div
|
<div
|
||||||
className="w-1.5 cursor-col-resize shrink-0 relative group hover:bg-primary/20 transition-colors"
|
className="w-1.5 cursor-col-resize shrink-0 relative group hover:bg-primary/20 transition-colors"
|
||||||
onMouseDown={handleResizeStart}
|
onMouseDown={handleResizeStart}
|
||||||
@@ -904,10 +958,10 @@ const formatPreviewContent = (content: string) => {
|
|||||||
<div className="absolute inset-y-0 -left-1 -right-1" />
|
<div className="absolute inset-y-0 -left-1 -right-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat area - right panel */}
|
{/* ── CHAT AREA (right panel) ── */}
|
||||||
{conversation ? (
|
{conversation ? (
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
{/* Chat header */}
|
{/* Chat header: avatar, name, actions */}
|
||||||
<div className="flex items-center justify-between gap-4 px-6 h-16 border-b shrink-0">
|
<div className="flex items-center justify-between gap-4 px-6 h-16 border-b shrink-0">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Avatar className="h-9 w-9 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(otherParticipant(conversation).avatar)}>
|
<Avatar className="h-9 w-9 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(otherParticipant(conversation).avatar)}>
|
||||||
@@ -950,7 +1004,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* ── MESSAGES ── */}
|
||||||
<div className="flex-1 overflow-y-auto p-6" style={{ scrollbarWidth: "thin", scrollbarColor: "hsl(var(--muted-foreground) / 0.3) transparent" }}>
|
<div className="flex-1 overflow-y-auto p-6" style={{ scrollbarWidth: "thin", scrollbarColor: "hsl(var(--muted-foreground) / 0.3) transparent" }}>
|
||||||
<div className="space-y-4 min-h-0">
|
<div className="space-y-4 min-h-0">
|
||||||
{messages.map((msg) => {
|
{messages.map((msg) => {
|
||||||
@@ -1041,7 +1095,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
{stickerData.emoji ? (
|
{stickerData.emoji ? (
|
||||||
<span className="text-7xl block text-center">{stickerData.emoji}</span>
|
<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>
|
</div>
|
||||||
) : voiceUrl ? (
|
) : voiceUrl ? (
|
||||||
@@ -1104,7 +1158,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input */}
|
{/* ── INPUT BAR ── */}
|
||||||
<div className="p-4 border-t shrink-0 space-y-2">
|
<div className="p-4 border-t shrink-0 space-y-2">
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -1233,9 +1287,11 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resize overlay */}
|
{/* Resize drag overlay (captures mouse events during resize) */}
|
||||||
{isResizing && <div className="fixed inset-0 z-50 cursor-col-resize" />}
|
{isResizing && <div className="fixed inset-0 z-50 cursor-col-resize" />}
|
||||||
|
|
||||||
|
{/* ── DIALOGS ── */}
|
||||||
|
|
||||||
{/* Report dialog */}
|
{/* Report dialog */}
|
||||||
<Dialog open={reportDialogOpen} onOpenChange={setReportDialogOpen}>
|
<Dialog open={reportDialogOpen} onOpenChange={setReportDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
@@ -1254,7 +1310,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Image preview dialog */}
|
{/* Image preview dialog (full-size) */}
|
||||||
<Dialog open={!!previewImageUrl} onOpenChange={(o) => { if (!o) setPreviewImageUrl(null) }}>
|
<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">
|
<DialogContent className="sm:max-w-3xl p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
||||||
<DialogTitle className="sr-only">Image preview</DialogTitle>
|
<DialogTitle className="sr-only">Image preview</DialogTitle>
|
||||||
@@ -1268,7 +1324,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Avatar preview dialog */}
|
{/* ── Avatar preview dialog ── */}
|
||||||
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
|
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
|
||||||
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
||||||
{previewAvatarUrl && (
|
{previewAvatarUrl && (
|
||||||
@@ -1281,7 +1337,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Forward dialog */}
|
{/* ── Forward message dialog ── */}
|
||||||
<Dialog open={forwardDialogOpen} onOpenChange={setForwardDialogOpen}>
|
<Dialog open={forwardDialogOpen} onOpenChange={setForwardDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Dashboard Page (/(dashboard)/dashboard)
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Route: /dashboard
|
||||||
|
// Purpose: Main overview page showing pipeline
|
||||||
|
// stats, trend sparklines, status/leads-per-month
|
||||||
|
// charts, and a recent-leads table. Polls the
|
||||||
|
// API every 30 seconds for live updates.
|
||||||
|
// Layout: Uses PageHeader + period selector,
|
||||||
|
// 6 stat cards, 2 charts, and a table.
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react"
|
import { useState, useEffect, useRef } from "react"
|
||||||
@@ -26,12 +38,23 @@ import {
|
|||||||
import { DashboardStats } from "@/types"
|
import { DashboardStats } from "@/types"
|
||||||
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DashboardPage
|
||||||
|
* ─────────────
|
||||||
|
* Fetches dashboard stats for a selected period
|
||||||
|
* and renders stat cards, charts, and a recent-
|
||||||
|
* leads table. Includes auto-polling every 30 s.
|
||||||
|
*
|
||||||
|
* @returns Full dashboard layout with loading
|
||||||
|
* skeleton states.
|
||||||
|
*/
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { websiteTheme } = useWebsiteTheme()
|
const { websiteTheme } = useWebsiteTheme()
|
||||||
const [period, setPeriod] = useState("6months")
|
const [period, setPeriod] = useState("6months")
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// ── Fetch dashboard stats from API ──
|
||||||
async function fetchStats(p: string) {
|
async function fetchStats(p: string) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/dashboard?period=${p}`)
|
const res = await fetch(`/api/dashboard?period=${p}`)
|
||||||
@@ -44,6 +67,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Initial fetch + 30-second polling ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStats(period)
|
fetchStats(period)
|
||||||
pollingRef.current = setInterval(() => fetchStats(period), 30000)
|
pollingRef.current = setInterval(() => fetchStats(period), 30000)
|
||||||
@@ -52,6 +76,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [period])
|
}, [period])
|
||||||
|
|
||||||
|
// ── Build stat-card configs from API response ──
|
||||||
const statCards = stats
|
const statCards = stats
|
||||||
? [
|
? [
|
||||||
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
|
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
|
||||||
@@ -66,6 +91,7 @@ export default function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 relative">
|
<div className="space-y-6 relative">
|
||||||
<div className="relative z-[1] space-y-6">
|
<div className="relative z-[1] space-y-6">
|
||||||
|
{/* ── Header with period filter ── */}
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={<span style={{fontFamily:"'Bangers',cursive",letterSpacing:"0.05em"}}>Dashboard</span>}
|
title={<span style={{fontFamily:"'Bangers',cursive",letterSpacing:"0.05em"}}>Dashboard</span>}
|
||||||
@@ -86,6 +112,7 @@ export default function DashboardPage() {
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
</div>
|
</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>
|
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Pipeline Overview</p>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
@@ -97,6 +124,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Analytics charts ── */}
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Analytics</p>
|
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Analytics</p>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
@@ -104,8 +132,11 @@ export default function DashboardPage() {
|
|||||||
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
|
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Recent leads table ── */}
|
||||||
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Themed decorative overlay (Spidey theme) ── */}
|
||||||
{websiteTheme === "spidey" && (
|
{websiteTheme === "spidey" && (
|
||||||
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
|
<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">
|
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
@@ -5,11 +17,21 @@ import { useRouter } from "next/navigation"
|
|||||||
import { useUser } from "@/providers/user-provider"
|
import { useUser } from "@/providers/user-provider"
|
||||||
import { Mail, Clock } from "lucide-react"
|
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() {
|
export default function EmailsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
const [emails, setEmails] = useState<any[]>([])
|
const [emails, setEmails] = useState<any[]>([])
|
||||||
|
|
||||||
|
// ── Auth guard + fetch emails ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) { router.push("/login"); return }
|
if (!user) { router.push("/login"); return }
|
||||||
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); 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">
|
<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.
|
Emails are stored locally. To actually deliver them, add <code className="bg-muted px-1 rounded">EMAIL_API_KEY</code> to your .env.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* ── Email list or empty state ── */}
|
||||||
{emails.length === 0 ? (
|
{emails.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No emails sent yet.</p>
|
<p className="text-sm text-muted-foreground">No emails sent yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{emails.map((e: any) => {
|
{emails.map((e: any) => {
|
||||||
|
// Extract "With:" metadata from email body for display
|
||||||
const withMatch = e.bodyText?.match(/With:\s*(.+)/)
|
const withMatch = e.bodyText?.match(/With:\s*(.+)/)
|
||||||
const withName = withMatch ? withMatch[1].trim() : null
|
const withName = withMatch ? withMatch[1].trim() : null
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Lead Details Page (/(dashboard)/leads/[id])
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Route: /leads/:id
|
||||||
|
// Purpose: Full detail view for a single lead.
|
||||||
|
// Displays company/contact info, notes timeline,
|
||||||
|
// activity log, and quick actions. Supports
|
||||||
|
// inline status changes with optimistic UI.
|
||||||
|
// Layout: 3-column grid — 2-col main area
|
||||||
|
// (details card + notes) + 1-col sidebar
|
||||||
|
// (activity + quick actions). Uses framer-motion
|
||||||
|
// for staggered entrance animations.
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { useState, useEffect, useCallback } from "react"
|
||||||
@@ -20,17 +34,31 @@ import {
|
|||||||
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
|
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
|
||||||
import { Lead, Note } from "@/types"
|
import { Lead, Note } from "@/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LeadDetailsPage
|
||||||
|
* ───────────────
|
||||||
|
* Fetches lead + notes in parallel on mount.
|
||||||
|
* Handles loading, not-found, and error states.
|
||||||
|
* Status select uses optimistic updates with
|
||||||
|
* rollback on failure.
|
||||||
|
*
|
||||||
|
* @param params - Next.js route params (Promise
|
||||||
|
* unwrapped via React.use()).
|
||||||
|
* @returns Lead detail layout.
|
||||||
|
*/
|
||||||
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params)
|
const { id } = use(params)
|
||||||
const [lead, setLead] = useState<Lead | null>(null)
|
const [lead, setLead] = useState<Lead | null>(null)
|
||||||
const [leadNotes, setLeadNotes] = useState<Note[]>([])
|
const [leadNotes, setLeadNotes] = useState<Note[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// ── Fetch notes for this lead ──
|
||||||
const fetchNotes = useCallback(async () => {
|
const fetchNotes = useCallback(async () => {
|
||||||
const notesRes = await fetch(`/api/leads/${id}/notes`)
|
const notesRes = await fetch(`/api/leads/${id}/notes`)
|
||||||
if (notesRes.ok) setLeadNotes(await notesRes.json())
|
if (notesRes.ok) setLeadNotes(await notesRes.json())
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
// ── Initial data load ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
@@ -47,6 +75,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [id, fetchNotes])
|
}, [id, fetchNotes])
|
||||||
|
|
||||||
|
// ── Loading state ──
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[60vh]">
|
<div className="flex items-center justify-center h-[60vh]">
|
||||||
@@ -55,6 +84,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Not-found state ──
|
||||||
if (!lead) {
|
if (!lead) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[60vh]">
|
<div className="flex items-center justify-center h-[60vh]">
|
||||||
@@ -74,6 +104,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* ── Back link ── */}
|
||||||
<Link
|
<Link
|
||||||
href="/leads"
|
href="/leads"
|
||||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
@@ -82,6 +113,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
|||||||
Back to leads
|
Back to leads
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* ── Header: company name, status badge, status select, edit button ── */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -94,6 +126,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
|||||||
<Select
|
<Select
|
||||||
value={lead.status}
|
value={lead.status}
|
||||||
onValueChange={async (v) => {
|
onValueChange={async (v) => {
|
||||||
|
// Optimistic update — revert on fail
|
||||||
const previousStatus = lead.status
|
const previousStatus = lead.status
|
||||||
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
|
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
|
||||||
try {
|
try {
|
||||||
@@ -125,7 +158,9 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
|||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
{/* ── Main content grid ── */}
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* ── Left: Details + Notes ── */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -152,6 +187,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right sidebar: Activity + Quick Actions ── */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 20 }}
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Create Lead Page (/(dashboard)/leads/new)
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Route: /leads/new
|
||||||
|
// Purpose: Form to create a new lead. Uses
|
||||||
|
// react-hook-form + zod for validation.
|
||||||
|
// On success, fires a notification and
|
||||||
|
// redirects to the leads list.
|
||||||
|
// Layout: Back link, heading, Card with form
|
||||||
|
// fields in a 2-col grid + textarea + user
|
||||||
|
// assignment select.
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
@@ -30,6 +43,7 @@ import { User } from "@/types"
|
|||||||
import { useNotifications } from "@/providers/notification-provider"
|
import { useNotifications } from "@/providers/notification-provider"
|
||||||
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
|
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
|
||||||
|
|
||||||
|
/** Zod schema for lead creation form validation. */
|
||||||
const leadFormSchema = z.object({
|
const leadFormSchema = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
contactName: z.string().min(1, "Contact name is required"),
|
contactName: z.string().min(1, "Contact name is required"),
|
||||||
@@ -43,12 +57,22 @@ const leadFormSchema = z.object({
|
|||||||
|
|
||||||
type LeadFormValues = z.infer<typeof leadFormSchema>
|
type LeadFormValues = z.infer<typeof leadFormSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CreateLeadPage
|
||||||
|
* ──────────────
|
||||||
|
* Zod-validated lead creation form. Fetches the
|
||||||
|
* user list for lead assignment on mount. On
|
||||||
|
* submit POSTs to /api/leads and navigates back.
|
||||||
|
*
|
||||||
|
* @returns Lead creation form layout.
|
||||||
|
*/
|
||||||
export default function CreateLeadPage() {
|
export default function CreateLeadPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { addNotification } = useNotifications()
|
const { addNotification } = useNotifications()
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [users, setUsers] = useState<User[]>([])
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
|
||||||
|
// ── Fetch available users for assignment ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/users")
|
fetch("/api/users")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -70,9 +94,11 @@ export default function CreateLeadPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Submit handler ──
|
||||||
async function onSubmit(values: LeadFormValues) {
|
async function onSubmit(values: LeadFormValues) {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
|
// Transform "none" sentinel value to null
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
|
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
|
||||||
@@ -84,6 +110,7 @@ export default function CreateLeadPage() {
|
|||||||
})
|
})
|
||||||
if (!res.ok) throw new Error("Failed to create lead")
|
if (!res.ok) throw new Error("Failed to create lead")
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
// Notify other parts of the app
|
||||||
addNotification("lead_created", "New Lead Created", `${values.companyName} — ${values.contactName}`, `/leads/${data.id}`)
|
addNotification("lead_created", "New Lead Created", `${values.companyName} — ${values.contactName}`, `/leads/${data.id}`)
|
||||||
router.push("/leads")
|
router.push("/leads")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -95,6 +122,7 @@ export default function CreateLeadPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* ── Back navigation ── */}
|
||||||
<Link
|
<Link
|
||||||
href="/leads"
|
href="/leads"
|
||||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
@@ -108,6 +136,7 @@ export default function CreateLeadPage() {
|
|||||||
<p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p>
|
<p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Lead form ── */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -256,6 +285,7 @@ export default function CreateLeadPage() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{/* ── Submit / Cancel buttons ── */}
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<Button type="submit" disabled={saving}>
|
<Button type="submit" disabled={saving}>
|
||||||
{saving ? "Saving..." : "Create Lead"}
|
{saving ? "Saving..." : "Create Lead"}
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Leads List Page (/(dashboard)/leads)
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Route: /leads
|
||||||
|
// Purpose: List, search, filter, and navigate to
|
||||||
|
// leads. Supports status/period filters and a
|
||||||
|
// text search. "Create" button navigates to
|
||||||
|
// /leads/new.
|
||||||
|
// Layout: PageHeader + toolbar (search + filters)
|
||||||
|
// + LeadsTable.
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
@@ -7,6 +19,15 @@ import { LeadsTable } from "@/components/leads/leads-table"
|
|||||||
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
||||||
import { Lead } from "@/types"
|
import { Lead } from "@/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LeadsPage
|
||||||
|
* ─────────
|
||||||
|
* Fetches leads from the API whenever search,
|
||||||
|
* status, or period filters change. Passes data
|
||||||
|
* and loading state to the table and toolbar.
|
||||||
|
*
|
||||||
|
* @returns Leads list layout.
|
||||||
|
*/
|
||||||
export default function LeadsPage() {
|
export default function LeadsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
@@ -15,6 +36,7 @@ export default function LeadsPage() {
|
|||||||
const [leadsData, setLeadsData] = useState<Lead[]>([])
|
const [leadsData, setLeadsData] = useState<Lead[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// ── Fetch leads with current filters ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (search) params.set("search", search)
|
if (search) params.set("search", search)
|
||||||
@@ -38,6 +60,7 @@ export default function LeadsPage() {
|
|||||||
description="Manage and track your sales leads"
|
description="Manage and track your sales leads"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── Toolbar + Table ── */}
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="rounded-lg border bg-card">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<LeadsTableToolbar
|
<LeadsTableToolbar
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Profile Page (/(dashboard)/profile)
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Route: /profile
|
||||||
|
// Purpose: View and edit the current user's
|
||||||
|
// profile — avatar upload, personal info,
|
||||||
|
// role, join date, and status.
|
||||||
|
// Layout: Two-column grid — left card with avatar
|
||||||
|
// + metadata badges, right card with account
|
||||||
|
// details in a 2-column grid.
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useRef } from "react"
|
import { useRef } from "react"
|
||||||
@@ -9,19 +21,31 @@ import { useUser } from "@/providers/user-provider"
|
|||||||
import { Mail, Calendar, Shield, Activity, Camera } from "lucide-react"
|
import { Mail, Calendar, Shield, Activity, Camera } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProfilePage
|
||||||
|
* ───────────
|
||||||
|
* Displays the currently logged-in user's profile.
|
||||||
|
* Allows avatar upload (PNG/JPEG) via a hidden
|
||||||
|
* file input with a hover-triggered camera overlay.
|
||||||
|
*
|
||||||
|
* @returns Profile layout or null if no user.
|
||||||
|
*/
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, updateAvatar } = useUser()
|
const { user, updateAvatar } = useUser()
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// ── Avatar upload handler ──
|
||||||
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
// Validate file type — only PNG and JPEG accepted
|
||||||
const validTypes = ["image/png", "image/jpeg"]
|
const validTypes = ["image/png", "image/jpeg"]
|
||||||
if (!validTypes.includes(file.type)) {
|
if (!validTypes.includes(file.type)) {
|
||||||
toast.error("Only PNG and JPEG files are allowed")
|
toast.error("Only PNG and JPEG files are allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Read as data URL, then POST to API
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = async () => {
|
reader.onload = async () => {
|
||||||
const dataUrl = reader.result as string
|
const dataUrl = reader.result as string
|
||||||
@@ -51,8 +75,10 @@ export default function ProfilePage() {
|
|||||||
<PageHeader title="Profile" description="Your account information" />
|
<PageHeader title="Profile" description="Your account information" />
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* ── Left column: Avatar + quick info ── */}
|
||||||
<Card className="lg:col-span-1">
|
<Card className="lg:col-span-1">
|
||||||
<CardContent className="flex flex-col items-center pt-8">
|
<CardContent className="flex flex-col items-center pt-8">
|
||||||
|
{/* Avatar with hover-to-upload overlay */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Avatar className="h-24 w-24">
|
<Avatar className="h-24 w-24">
|
||||||
<AvatarImage src={user.avatar} />
|
<AvatarImage src={user.avatar} />
|
||||||
@@ -77,6 +103,7 @@ export default function ProfilePage() {
|
|||||||
<Badge variant="secondary" className="mt-1 capitalize">
|
<Badge variant="secondary" className="mt-1 capitalize">
|
||||||
{user.role}
|
{user.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{/* Metadata rows */}
|
||||||
<div className="mt-6 flex w-full flex-col gap-3 text-sm">
|
<div className="mt-6 flex w-full flex-col gap-3 text-sm">
|
||||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||||
@@ -100,6 +127,7 @@ export default function ProfilePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Right column: Full account details ── */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Settings Page (/(dashboard)/settings)
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Route: /settings
|
||||||
|
// Purpose: Tabbed settings panel covering company
|
||||||
|
// info, user preferences, theme choices, and
|
||||||
|
// notification configuration.
|
||||||
|
// Layout: PageHeader + <Tabs> with 4 tab panels.
|
||||||
|
// Each tab renders a dedicated settings form
|
||||||
|
// component.
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { PageHeader } from "@/components/shared/page-header"
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
@@ -8,14 +20,23 @@ import { ThemeSettings } from "@/components/settings/theme-settings"
|
|||||||
import { NotificationSettings } from "@/components/settings/notification-settings"
|
import { NotificationSettings } from "@/components/settings/notification-settings"
|
||||||
import { Building2, User, Palette, Bell } from "lucide-react"
|
import { Building2, User, Palette, Bell } from "lucide-react"
|
||||||
|
|
||||||
export default function SettingsPage() {
|
/** Tab configuration: value, label, icon, and the component to render. */
|
||||||
const tabs = [
|
const settingsTabs = [
|
||||||
{ value: "company", label: "Company", icon: Building2, component: CompanySettingsForm },
|
{ value: "company", label: "Company", icon: Building2, component: CompanySettingsForm },
|
||||||
{ value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm },
|
{ value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm },
|
||||||
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
|
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
|
||||||
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SettingsPage
|
||||||
|
* ────────────
|
||||||
|
* Renders a tabbed settings interface. Each tab
|
||||||
|
* dynamically renders its associated form component.
|
||||||
|
*
|
||||||
|
* @returns Settings layout with navigation tabs.
|
||||||
|
*/
|
||||||
|
export default function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -23,16 +44,17 @@ export default function SettingsPage() {
|
|||||||
description="Manage your CRM configuration and preferences"
|
description="Manage your CRM configuration and preferences"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── Tabbed settings sections ── */}
|
||||||
<Tabs defaultValue="company" className="space-y-6">
|
<Tabs defaultValue="company" className="space-y-6">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{tabs.map((tab) => (
|
{settingsTabs.map((tab) => (
|
||||||
<TabsTrigger key={tab.value} value={tab.value} className="gap-2">
|
<TabsTrigger key={tab.value} value={tab.value} className="gap-2">
|
||||||
<tab.icon className="h-4 w-4" />
|
<tab.icon className="h-4 w-4" />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{tabs.map((tab) => (
|
{settingsTabs.map((tab) => (
|
||||||
<TabsContent key={tab.value} value={tab.value}>
|
<TabsContent key={tab.value} value={tab.value}>
|
||||||
<tab.component />
|
<tab.component />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Users Page (/(dashboard)/users)
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
// Route: /users
|
||||||
|
// Purpose: Team management — list, search, and
|
||||||
|
// create users. Shows summary stats cards and
|
||||||
|
// a searchable table.
|
||||||
|
// Layout: PageHeader + stat grid + search bar +
|
||||||
|
// UsersTable + inline UserFormDialog.
|
||||||
|
// ───────────────────────────────────────────────
|
||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { useState, useEffect, useCallback } from "react"
|
||||||
@@ -10,6 +21,16 @@ import { useUser } from "@/providers/user-provider"
|
|||||||
import { Plus, Users as UsersIcon, Search } from "lucide-react"
|
import { Plus, Users as UsersIcon, Search } from "lucide-react"
|
||||||
import type { User } from "@/types"
|
import type { User } from "@/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UsersPage
|
||||||
|
* ─────────
|
||||||
|
* Fetches all users on mount, computes summary
|
||||||
|
* stats (total / active / admins / sales), and
|
||||||
|
* renders a searchable table. "Add User" button
|
||||||
|
* opens a modal dialog for creation.
|
||||||
|
*
|
||||||
|
* @returns Users management layout.
|
||||||
|
*/
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
const [users, setUsers] = useState<User[]>([])
|
const [users, setUsers] = useState<User[]>([])
|
||||||
@@ -17,6 +38,7 @@ export default function UsersPage() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
// ── Fetch all users ──
|
||||||
const fetchUsers = useCallback(async () => {
|
const fetchUsers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/users")
|
const res = await fetch("/api/users")
|
||||||
@@ -35,6 +57,7 @@ export default function UsersPage() {
|
|||||||
fetchUsers()
|
fetchUsers()
|
||||||
}, [fetchUsers])
|
}, [fetchUsers])
|
||||||
|
|
||||||
|
// ── Derived stats ──
|
||||||
const activeUsers = users.filter((u) => u.active !== false)
|
const activeUsers = users.filter((u) => u.active !== false)
|
||||||
const admins = users.filter((u) => u.role === "admin")
|
const admins = users.filter((u) => u.role === "admin")
|
||||||
|
|
||||||
@@ -45,6 +68,7 @@ export default function UsersPage() {
|
|||||||
{ label: "Sales", value: users.filter((u) => u.role === "sales").length, color: "bg-orange-500/10", textColor: "text-orange-500" },
|
{ label: "Sales", value: users.filter((u) => u.role === "sales").length, color: "bg-orange-500/10", textColor: "text-orange-500" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ── Client-side search filter by name/email ──
|
||||||
const filtered = users.filter((u) => {
|
const filtered = users.filter((u) => {
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
@@ -53,6 +77,7 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* ── Header with "Add User" action ── */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Users"
|
title="Users"
|
||||||
description="Manage team members and their roles"
|
description="Manage team members and their roles"
|
||||||
@@ -63,6 +88,7 @@ export default function UsersPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
{/* ── Summary stat cards ── */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||||
{stats.map((s) => (
|
{stats.map((s) => (
|
||||||
<div key={s.label} className="rounded-lg border bg-card p-4">
|
<div key={s.label} className="rounded-lg border bg-card p-4">
|
||||||
@@ -81,6 +107,7 @@ export default function UsersPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Search input ── */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -91,10 +118,12 @@ export default function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Users data table ── */}
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="rounded-lg border bg-card">
|
||||||
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
|
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Create-user dialog ── */}
|
||||||
<UserFormDialog
|
<UserFormDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onOpenChange={setCreateOpen}
|
onOpenChange={setCreateOpen}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
// ── AI: Chat ─────────────────────────────────────────────────────────────────
|
||||||
|
// POST /api/ai/chat — Send a message to the AI assistant and get a response
|
||||||
|
//
|
||||||
|
// Auth: authenticated
|
||||||
|
// Body: { message: string }
|
||||||
|
// Response: { response: string }
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { chatWithAI } from "@/lib/ai"
|
import { chatWithAI } from "@/lib/ai"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -14,6 +23,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Message is required" }, { status: 400 })
|
return NextResponse.json({ error: "Message is required" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract JWT from session cookie to forward to the AI service for auth
|
||||||
const sessionCookie = request.cookies.get("session")
|
const sessionCookie = request.cookies.get("session")
|
||||||
const jwtToken = sessionCookie?.value
|
const jwtToken = sessionCookie?.value
|
||||||
if (!jwtToken) {
|
if (!jwtToken) {
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
|
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
|
||||||
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
|
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -21,6 +32,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
|
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
|
||||||
|
|
||||||
|
// Route to search or trending endpoint based on type param
|
||||||
let url: string
|
let url: string
|
||||||
if (type === "search" && query) {
|
if (type === "search" && query) {
|
||||||
url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}&rating=g`
|
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()
|
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) => ({
|
const gifs = (data.data || []).map((gif: any) => ({
|
||||||
id: gif.id,
|
id: gif.id,
|
||||||
title: gif.title,
|
title: gif.title,
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
// ── AI: Job Listing ──────────────────────────────────────────────────────────
|
||||||
|
// GET /api/ai/jobs — Fetch AI-related job postings
|
||||||
|
//
|
||||||
|
// Auth: authenticated; role must be sales, admin, or super_admin
|
||||||
|
// Returns an empty array on failure (graceful degradation).
|
||||||
|
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { fetchJobs } from "@/lib/ai"
|
import { fetchJobs } from "@/lib/ai"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
@@ -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 { NextRequest } from "next/server"
|
||||||
import {
|
import {
|
||||||
comparePassword,
|
comparePassword,
|
||||||
@@ -13,6 +19,7 @@ import {
|
|||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
} from "@/lib/auth"
|
} from "@/lib/auth"
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function jsonResponse(data: unknown, status: number) {
|
function jsonResponse(data: unknown, status: number) {
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
@@ -21,12 +28,15 @@ function jsonResponse(data: unknown, status: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { email, username, password } = await request.json()
|
const { email, username, password } = await request.json()
|
||||||
|
|
||||||
const credential = email || username
|
const credential = email || username
|
||||||
|
|
||||||
|
// Validate that both credential and password are present
|
||||||
if (!credential || !password) {
|
if (!credential || !password) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{ error: "Email/Username and password are required." },
|
{ 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) {
|
if (credential.trim().length === 0 || password.trim().length === 0) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{ error: "Credentials cannot be empty." },
|
{ 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 =
|
const ipAddress =
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
request.headers.get("x-real-ip") ||
|
request.headers.get("x-real-ip") ||
|
||||||
@@ -58,6 +70,7 @@ export async function POST(request: NextRequest) {
|
|||||||
dbUser = await getUserByUsername(credential)
|
dbUser = await getUserByUsername(credential)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If user does not exist, log attempt and return generic error
|
||||||
if (!dbUser) {
|
if (!dbUser) {
|
||||||
await recordLoginAttempt(
|
await recordLoginAttempt(
|
||||||
null,
|
null,
|
||||||
@@ -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)
|
const lockStatus = await isAccountLocked(dbUser)
|
||||||
if (lockStatus.locked) {
|
if (lockStatus.locked) {
|
||||||
await recordLoginAttempt(
|
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)
|
const valid = await comparePassword(password, dbUser.password_hash)
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
await incrementFailedAttempts(dbUser.id)
|
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 resetFailedAttempts(dbUser.id)
|
||||||
await recordLoginAttempt(
|
await recordLoginAttempt(
|
||||||
dbUser.id,
|
dbUser.id,
|
||||||
@@ -120,6 +136,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const user = mapDbUserToSessionUser(dbUser)
|
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" : ""}`
|
const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}`
|
||||||
|
|
||||||
return new Response(JSON.stringify({ user }), {
|
return new Response(JSON.stringify({ user }), {
|
||||||
|
|||||||
@@ -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"
|
import { SESSION_COOKIE } from "@/lib/auth"
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
|
// Overwrite cookie with an immediate expiry (Max-Age=0)
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"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) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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 { NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import {
|
import {
|
||||||
getSessionUser,
|
getSessionUser,
|
||||||
@@ -6,12 +15,15 @@ import {
|
|||||||
} from "@/lib/auth"
|
} from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
if (!sessionUser) {
|
if (!sessionUser) {
|
||||||
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
// Only super_admin is allowed to recover other users' passwords
|
||||||
if (sessionUser.role !== "super_admin") {
|
if (sessionUser.role !== "super_admin") {
|
||||||
return NextResponse.json({ error: "Only SUPER_ADMIN can recover passwords." }, { status: 403 })
|
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)
|
await setSessionContext(sessionUser.id, ipAddress)
|
||||||
|
|
||||||
|
// Fetch the target user (excluding soft-deleted records)
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT id, username, email, first_name, last_name, password_encrypted
|
`SELECT id, username, email, first_name, last_name, password_encrypted
|
||||||
FROM users WHERE id = $1 AND deleted_at IS NULL`,
|
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 })
|
return NextResponse.json({ error: "User not found." }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guard against missing encrypted password column
|
||||||
if (!user.password_encrypted) {
|
if (!user.password_encrypted) {
|
||||||
return NextResponse.json({ error: "No encrypted password stored for this user." }, { status: 404 })
|
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)
|
const plaintextPassword = await decryptPassword(user.password_encrypted)
|
||||||
if (!plaintextPassword) {
|
if (!plaintextPassword) {
|
||||||
return NextResponse.json({ error: "Failed to decrypt password. Master key may have changed." }, { status: 500 })
|
return NextResponse.json({ error: "Failed to decrypt password. Master key may have changed." }, { status: 500 })
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { getSessionUser, setSessionContext } from "@/lib/auth"
|
import { getSessionUser, setSessionContext } from "@/lib/auth"
|
||||||
|
|
||||||
|
// ── PATCH ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest, { params: routeParams }: { params: Promise<{ id: string }> }) {
|
export async function PATCH(request: NextRequest, { params: routeParams }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const sessionUser = await getSessionUser()
|
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 })
|
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])
|
const existing = await query("SELECT id, status FROM bug_reports WHERE id = $1", [id])
|
||||||
if (existing.rows.length === 0) {
|
if (existing.rows.length === 0) {
|
||||||
return NextResponse.json({ error: "Bug report not found." }, { status: 404 })
|
return NextResponse.json({ error: "Bug report not found." }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build dynamic SET clause for partial updates
|
||||||
const updates: string[] = []
|
const updates: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
let paramIndex = 1
|
let paramIndex = 1
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
@@ -38,6 +47,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
@@ -54,6 +65,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
const offset = parseInt(searchParams.get("offset") || "0", 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,
|
let sql = `SELECT br.id, br.title, br.description, br.severity, br.page_url,
|
||||||
br.screenshot_url, br.status, br.resolution_notes,
|
br.screenshot_url, br.status, br.resolution_notes,
|
||||||
br.created_at, br.updated_at,
|
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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
@@ -5,6 +12,8 @@ import { unlink } from "node:fs/promises"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { existsSync } from "node:fs"
|
import { existsSync } from "node:fs"
|
||||||
|
|
||||||
|
// ── DELETE ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string; messageId: string }> },
|
{ 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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
import { hasBlockedCodeExtension } from "@/lib/blocked-extensions"
|
import { hasBlockedCodeExtension } from "@/lib/blocked-extensions"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
@@ -37,6 +47,7 @@ export async function GET(
|
|||||||
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL`
|
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL`
|
||||||
const msgParams: any[] = [id]
|
const msgParams: any[] = [id]
|
||||||
|
|
||||||
|
// Optional cursor-based pagination: fetch messages before a given timestamp
|
||||||
if (before) {
|
if (before) {
|
||||||
msgSql += ` AND m.created_at < $2`
|
msgSql += ` AND m.created_at < $2`
|
||||||
msgParams.push(before)
|
msgParams.push(before)
|
||||||
@@ -46,6 +57,7 @@ export async function GET(
|
|||||||
msgSql += ` LIMIT $${msgParams.length + 1} OFFSET $${msgParams.length + 2}`
|
msgSql += ` LIMIT $${msgParams.length + 1} OFFSET $${msgParams.length + 2}`
|
||||||
msgParams.push(limit, offset)
|
msgParams.push(limit, offset)
|
||||||
|
|
||||||
|
// Fetch messages and other participant's last_read_at in parallel
|
||||||
const [msgResult, otherReadResult] = await Promise.all([
|
const [msgResult, otherReadResult] = await Promise.all([
|
||||||
query(msgSql, msgParams),
|
query(msgSql, msgParams),
|
||||||
query(
|
query(
|
||||||
@@ -59,6 +71,7 @@ export async function GET(
|
|||||||
? new Date(otherReadResult.rows[0].last_read_at).getTime()
|
? new Date(otherReadResult.rows[0].last_read_at).getTime()
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
|
// Mark outgoing messages as "read" if the other participant has seen them
|
||||||
const messages = msgResult.rows.map((row: any) => ({
|
const messages = msgResult.rows.map((row: any) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
@@ -80,6 +93,8 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
@@ -104,7 +119,7 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
|
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 {
|
try {
|
||||||
const parsed = JSON.parse(content)
|
const parsed = JSON.parse(content)
|
||||||
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
|
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
|
||||||
@@ -123,6 +138,7 @@ export async function POST(
|
|||||||
[id, user.id, content.trim()],
|
[id, user.id, content.trim()],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Bump the conversation's updated_at timestamp
|
||||||
await query(
|
await query(
|
||||||
`UPDATE conversations SET updated_at = NOW() WHERE id = $1`,
|
`UPDATE conversations SET updated_at = NOW() WHERE id = $1`,
|
||||||
[id],
|
[id],
|
||||||
@@ -131,6 +147,7 @@ export async function POST(
|
|||||||
const msg = result.rows[0]
|
const msg = result.rows[0]
|
||||||
const senderName = `${user.firstName} ${user.lastName}`
|
const senderName = `${user.firstName} ${user.lastName}`
|
||||||
|
|
||||||
|
// Notify the other participant about the new message
|
||||||
const otherResult = await query(
|
const otherResult = await query(
|
||||||
`SELECT user_id FROM conversation_participants
|
`SELECT user_id FROM conversation_participants
|
||||||
WHERE conversation_id = $1 AND user_id != $2`,
|
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 {
|
function formatTime(date: Date): string {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const isToday = date.toDateString() === now.toDateString()
|
const isToday = date.toDateString() === now.toDateString()
|
||||||
@@ -174,6 +197,8 @@ function formatTime(date: Date): string {
|
|||||||
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── DELETE ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
@@ -187,6 +212,7 @@ export async function DELETE(
|
|||||||
const messageId = body.messageId
|
const messageId = body.messageId
|
||||||
if (!messageId) return NextResponse.json({ error: "messageId required" }, { status: 400 })
|
if (!messageId) return NextResponse.json({ error: "messageId required" }, { status: 400 })
|
||||||
|
|
||||||
|
// Only the sender can soft-delete their own message
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND sender_id = $2 AND conversation_id = $3 RETURNING id`,
|
`UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND sender_id = $2 AND conversation_id = $3 RETURNING id`,
|
||||||
[messageId, user.id, id],
|
[messageId, user.id, id],
|
||||||
@@ -202,6 +228,8 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PATCH ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
@@ -218,6 +246,7 @@ export async function PATCH(
|
|||||||
return NextResponse.json({ error: "messageId and content required" }, { status: 400 })
|
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(
|
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`,
|
`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],
|
[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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
@@ -12,6 +21,7 @@ export async function POST(
|
|||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
|
// Update the participant's last_read_at to now
|
||||||
await query(
|
await query(
|
||||||
`UPDATE conversation_participants
|
`UPDATE conversation_participants
|
||||||
SET last_read_at = NOW()
|
SET last_read_at = NOW()
|
||||||
@@ -19,6 +29,7 @@ export async function POST(
|
|||||||
[id, user.id],
|
[id, user.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Clear any existing notifications for this conversation
|
||||||
await query(
|
await query(
|
||||||
`UPDATE notifications SET is_read = TRUE
|
`UPDATE notifications SET is_read = TRUE
|
||||||
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
|
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query, transaction } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
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(
|
const result = await query(
|
||||||
`SELECT
|
`SELECT
|
||||||
c.id,
|
c.id,
|
||||||
@@ -18,13 +31,18 @@ export async function GET() {
|
|||||||
u.email AS other_user_email,
|
u.email AS other_user_email,
|
||||||
u.phone AS other_user_phone,
|
u.phone AS other_user_phone,
|
||||||
u.avatar_url AS other_user_avatar_url,
|
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,
|
lm.last_message_data->>'content' 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->>'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
|
(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
|
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_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 conversation_participants cp ON cp.conversation_id = c.id
|
||||||
JOIN users u ON u.id = cp.user_id AND u.id != $1
|
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 (
|
WHERE c.id IN (
|
||||||
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
||||||
)
|
)
|
||||||
@@ -54,6 +72,8 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
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 })
|
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(
|
const existing = await query(
|
||||||
`SELECT c.id FROM conversations c
|
`SELECT c.id FROM conversations c
|
||||||
JOIN conversation_participants cp1 ON cp1.conversation_id = c.id AND cp1.user_id = $1
|
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 })
|
return NextResponse.json({ conversationId: existing.rows[0].id })
|
||||||
}
|
}
|
||||||
|
|
||||||
const convResult = await query(
|
// Create new conversation + participants in a single transaction
|
||||||
|
const result = await transaction(async (client) => {
|
||||||
|
const convResult = await client.query(
|
||||||
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
|
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
|
||||||
)
|
)
|
||||||
const conversationId = convResult.rows[0].id
|
const conversationId = convResult.rows[0].id
|
||||||
|
|
||||||
await query(
|
// Insert both participants with immediate last_read_at (auto-read)
|
||||||
|
await client.query(
|
||||||
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
||||||
[conversationId, user.id, userId],
|
[conversationId, user.id, userId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const otherUser = await query(
|
const otherResult = await client.query(
|
||||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||||
FROM users WHERE id = $1`,
|
FROM users WHERE id = $1`,
|
||||||
[userId],
|
[userId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const other = otherUser.rows[0]
|
return { conversationId, other: otherResult.rows[0] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const { conversationId, other } = result
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
conversation: {
|
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 {
|
function timeAgo(date: Date): string {
|
||||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||||
if (seconds < 60) return "now"
|
if (seconds < 60) return "now"
|
||||||
|
|||||||
+139
-78
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the { start, end } Date range for a named period.
|
||||||
|
*/
|
||||||
function getPeriodDateRange(period: string): { start: Date; end: Date } {
|
function getPeriodDateRange(period: string): { start: Date; end: Date } {
|
||||||
const end = new Date()
|
const end = new Date()
|
||||||
let start: Date
|
let start: Date
|
||||||
@@ -20,6 +33,9 @@ function getPeriodDateRange(period: string): { start: Date; end: Date } {
|
|||||||
return { start, end }
|
return { start, end }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the range for the *previous* period of the same length.
|
||||||
|
*/
|
||||||
function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } {
|
function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } {
|
||||||
const end = new Date(currentStart)
|
const end = new Date(currentStart)
|
||||||
const diff = end.getTime() - currentStart.getTime()
|
const diff = end.getTime() - currentStart.getTime()
|
||||||
@@ -34,6 +50,9 @@ const periodLabels: Record<string, string> = {
|
|||||||
"12months": "Last 12 months",
|
"12months": "Last 12 months",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps DB stage name to client-facing status string.
|
||||||
|
*/
|
||||||
function stageToStatus(name: string): string {
|
function stageToStatus(name: string): string {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "New": return "open"
|
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(
|
* Computes a trend object from current vs previous count.
|
||||||
`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
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
|
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
|
||||||
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
|
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
|
||||||
const pct = Math.round(((current - previous) / previous) * 100)
|
const pct = Math.round(((current - previous) / previous) * 100)
|
||||||
return { pct: Math.abs(pct), up: pct >= 0 }
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -128,6 +101,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const yearParam = searchParams.get("year")
|
const yearParam = searchParams.get("year")
|
||||||
let start: Date, end: Date, prevRange: { start: Date; end: Date }
|
let start: Date, end: Date, prevRange: { start: Date; end: Date }
|
||||||
if (yearParam) {
|
if (yearParam) {
|
||||||
|
// Year mode: compare full year to previous full year
|
||||||
const y = parseInt(yearParam)
|
const y = parseInt(yearParam)
|
||||||
start = new Date(y, 0, 1)
|
start = new Date(y, 0, 1)
|
||||||
end = new Date(y, 11, 31, 23, 59, 59)
|
end = new Date(y, 11, 31, 23, 59, 59)
|
||||||
@@ -138,19 +112,108 @@ export async function GET(request: NextRequest) {
|
|||||||
prevRange = getPreviousPeriodRange(period, start)
|
prevRange = getPreviousPeriodRange(period, start)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [currentLeads, prevLeads] = await Promise.all([
|
// Non-admin users only see their own leads
|
||||||
fetchLeadsInRange(start, end, user.id, isAdmin),
|
const ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3"
|
||||||
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
|
|
||||||
])
|
|
||||||
|
|
||||||
const currentCounts = countStatuses(currentLeads)
|
// ── Status counts for current period (SQL aggregation) ──────────
|
||||||
const prevCounts = countStatuses(prevLeads)
|
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 closedLeads = currentCounts.closed
|
||||||
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
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,
|
id: r.id,
|
||||||
companyName: r.company_name || "",
|
companyName: r.company_name || "",
|
||||||
contactName: r.contact_name,
|
contactName: r.contact_name,
|
||||||
@@ -158,7 +221,7 @@ export async function GET(request: NextRequest) {
|
|||||||
phone: r.phone || "",
|
phone: r.phone || "",
|
||||||
source: "",
|
source: "",
|
||||||
description: r.notes || "",
|
description: r.notes || "",
|
||||||
status: r.status,
|
status: stageToStatus(r.stage_name),
|
||||||
assignedUserId: r.assigned_to,
|
assignedUserId: r.assigned_to,
|
||||||
assignedUser: r.assigned_to ? {
|
assignedUser: r.assigned_to ? {
|
||||||
id: r.user_id,
|
id: r.user_id,
|
||||||
@@ -170,17 +233,15 @@ export async function GET(request: NextRequest) {
|
|||||||
updatedAt: r.updated_at,
|
updatedAt: r.updated_at,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period, yearParam ? { start, end } : undefined)
|
// ── Trends (current vs previous period) ─────────────────────────
|
||||||
|
|
||||||
const trends = {
|
const trends = {
|
||||||
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
totalLeads: computeTrend(totalLeads, prevTotal),
|
||||||
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
|
|
||||||
openLeads: computeTrend(currentCounts.open, prevCounts.open),
|
openLeads: computeTrend(currentCounts.open, prevCounts.open),
|
||||||
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
|
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
|
||||||
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
|
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
|
||||||
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
|
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
|
||||||
conversionRate: computeTrend(conversionRate,
|
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 = {
|
const stats = {
|
||||||
@@ -192,9 +253,9 @@ export async function GET(request: NextRequest) {
|
|||||||
ignoredLeads: currentCounts.ignored,
|
ignoredLeads: currentCounts.ignored,
|
||||||
conversionRate,
|
conversionRate,
|
||||||
monthlyBreakdown,
|
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,
|
trends,
|
||||||
recentLeads: mappedLeads.slice(0, 10),
|
recentLeads,
|
||||||
statusDistribution: [
|
statusDistribution: [
|
||||||
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
||||||
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
|
|||||||
@@ -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 { NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { buildIcs } from "@/lib/ics"
|
import { buildIcs } from "@/lib/ics"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -10,6 +18,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
|
// Fetch event details with creator and participant info; enforce ownership
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT e.id, e.user_id, e.participant_id, e.title, e.description, e.event_type,
|
`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,
|
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]
|
const r = result.rows[0]
|
||||||
|
|
||||||
|
// Build the ICS string via the shared utility
|
||||||
const icsContent = buildIcs({
|
const icsContent = buildIcs({
|
||||||
uid: r.id,
|
uid: r.id,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { sendEventRescheduled } from "@/lib/email"
|
import { sendEventRescheduled } from "@/lib/email"
|
||||||
|
|
||||||
|
// ── PATCH ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -11,6 +20,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
const { id } = await params
|
const { id } = await params
|
||||||
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, developerId, participantNotes, clientName, clientEmail, clientPhone } = await request.json()
|
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, developerId, participantNotes, clientName, clientEmail, clientPhone } = await request.json()
|
||||||
|
|
||||||
|
// Fetch current event with creator + participant details
|
||||||
const existing = await query(
|
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,
|
`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,
|
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 })
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-creators cannot change core event details (title, type, time, participants)
|
||||||
if (!isCreator && (
|
if (!isCreator && (
|
||||||
(title !== undefined && title !== old.title) ||
|
(title !== undefined && title !== old.title) ||
|
||||||
(eventType !== undefined && eventType !== old.event_type) ||
|
(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 })
|
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(
|
const result = await query(
|
||||||
`UPDATE scheduled_events SET
|
`UPDATE scheduled_events SET
|
||||||
title = COALESCE($1, title),
|
title = COALESCE($1, title),
|
||||||
@@ -88,7 +100,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
|
|
||||||
const r = result.rows[0]
|
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) {
|
if (status === "completed" && r.lead_id) {
|
||||||
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Closed Won'")
|
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Closed Won'")
|
||||||
if (stageResult.rows.length > 0) {
|
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 ||
|
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.title !== old.title || r.event_type !== old.event_type ||
|
||||||
r.status !== old.status
|
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))
|
}).catch((err) => console.error("Reschedule email error:", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch creator info to return in the response
|
||||||
const creatorResult = await query(
|
const creatorResult = await query(
|
||||||
`SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url,
|
`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
|
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 }> }) {
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
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 })
|
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only the event creator can delete
|
||||||
if (existing.rows[0].user_id !== user.id) {
|
if (existing.rows[0].user_id !== user.id) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-77
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query, transaction } from "@/lib/db"
|
||||||
import { sendEventConfirmation } from "@/lib/email"
|
import { sendEventConfirmation } from "@/lib/email"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -13,6 +22,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const end = searchParams.get("end")
|
const end = searchParams.get("end")
|
||||||
const status = searchParams.get("status")
|
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,
|
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.title, e.description, e.participant_notes, e.event_type,
|
||||||
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
|
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[] = []
|
const params: unknown[] = []
|
||||||
let idx = 1
|
let idx = 1
|
||||||
|
|
||||||
|
// Non-super_admins only see events where they are creator, participant, or developer
|
||||||
if (user.role !== "super_admin") {
|
if (user.role !== "super_admin") {
|
||||||
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx} OR e.developer_id = $${idx}`
|
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx} OR e.developer_id = $${idx}`
|
||||||
params.push(user.id)
|
params.push(user.id)
|
||||||
@@ -68,6 +79,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const result = await query(sql, params, user.id)
|
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) => ({
|
const events = result.rows.map((r: any) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
userId: r.user_id,
|
userId: r.user_id,
|
||||||
@@ -100,6 +112,8 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -115,91 +129,91 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!title) {
|
if (!title) {
|
||||||
return NextResponse.json({ error: "Title is required" }, { status: 400 })
|
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) {
|
if (eventType !== "website_creation" && !startTime) {
|
||||||
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
|
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await query(
|
// 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)
|
`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)
|
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`,
|
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,
|
user.id, participantId || null, developerId || null, leadId || null, conversationId || null,
|
||||||
participantId || null,
|
title, description || null, eventType || "website_creation", startTime || null,
|
||||||
developerId || null,
|
// For website_creation, endTime and durationMinutes are irrelevant
|
||||||
leadId || null,
|
|
||||||
conversationId || null,
|
|
||||||
title,
|
|
||||||
description || null,
|
|
||||||
eventType || "website_creation",
|
|
||||||
startTime || null,
|
|
||||||
eventType === "website_creation" ? null : (endTime || null),
|
eventType === "website_creation" ? null : (endTime || null),
|
||||||
eventType === "website_creation" ? null : (durationMinutes || null),
|
eventType === "website_creation" ? null : (durationMinutes || null),
|
||||||
clientName || null,
|
clientName || null, clientEmail || null, clientPhone || null,
|
||||||
clientEmail || null,
|
|
||||||
clientPhone || null,
|
|
||||||
],
|
],
|
||||||
user.id,
|
|
||||||
)
|
)
|
||||||
|
const r = ins.rows[0]
|
||||||
|
|
||||||
const r = result.rows[0]
|
// 2. Auto-update lead stage to "Qualified" when a website creation event is scheduled
|
||||||
|
|
||||||
// Auto-update lead to "pending" when a website creation event is created
|
|
||||||
if (r.event_type === "website_creation" && leadId) {
|
if (r.event_type === "website_creation" && leadId) {
|
||||||
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
|
const stageResult = await client.query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
|
||||||
if (stageResult.rows.length > 0) {
|
if (stageResult.rows.length > 0) {
|
||||||
await query(
|
await client.query(
|
||||||
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
|
"UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL",
|
||||||
[stageResult.rows[0].id, leadId],
|
[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) {
|
if (participantId && participantId !== user.id) {
|
||||||
await query(
|
notifications.push({
|
||||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
user_id: participantId, type: "event_scheduled",
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
title: `Meeting scheduled: ${title}`,
|
||||||
[
|
description: `${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
|
||||||
participantId,
|
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
|
||||||
"event_scheduled",
|
})
|
||||||
`Meeting scheduled: ${title}`,
|
|
||||||
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
|
|
||||||
`/calendar`,
|
|
||||||
r.id,
|
|
||||||
"scheduled_event",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify developer
|
|
||||||
if (developerId && developerId !== user.id) {
|
if (developerId && developerId !== user.id) {
|
||||||
await query(
|
notifications.push({
|
||||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
user_id: developerId, type: "event_scheduled",
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
title: `Project assigned: ${title}`,
|
||||||
[
|
description: `${user.firstName} ${user.lastName} assigned a project to you`,
|
||||||
developerId,
|
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
|
||||||
"event_scheduled",
|
})
|
||||||
`Project assigned: ${title}`,
|
}
|
||||||
`${user.firstName} ${user.lastName} assigned a project to you`,
|
if (notifications.length > 0) {
|
||||||
`/calendar`,
|
const placeholders = notifications.map((_, i) =>
|
||||||
r.id,
|
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7})`
|
||||||
"scheduled_event",
|
).join(", ")
|
||||||
],
|
const flatParams = notifications.flatMap(n => [n.user_id, n.type, n.title, n.description, n.link, n.context_id, n.context_type])
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) VALUES ${placeholders}`,
|
||||||
|
flatParams,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const participantResult = participantId ? await query(
|
// 4. Fetch participant + developer in one query
|
||||||
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
|
const idsToFetch = [participantId, developerId].filter(Boolean) as string[]
|
||||||
[participantId],
|
let usersMap: Record<string, { id: string; first_name: string; last_name: string; email: string }> = {}
|
||||||
) : null
|
if (idsToFetch.length > 0) {
|
||||||
const participant = participantResult?.rows[0] || null
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const developerResult = developerId ? await query(
|
return { r, usersMap }
|
||||||
`SELECT id, first_name, last_name, email FROM users WHERE id = $1`,
|
}, user.id)
|
||||||
[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({
|
sendEventConfirmation({
|
||||||
creatorName: `${user.firstName} ${user.lastName}`,
|
creatorName: `${user.firstName} ${user.lastName}`,
|
||||||
creatorEmail: user.email,
|
creatorEmail: user.email,
|
||||||
@@ -215,28 +229,16 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
event: {
|
event: {
|
||||||
id: r.id,
|
id: r.id, userId: r.user_id, participantId: r.participant_id, developerId: r.developer_id,
|
||||||
userId: r.user_id,
|
leadId: r.lead_id, conversationId: r.conversation_id,
|
||||||
participantId: r.participant_id,
|
title: r.title, description: r.description, participantNotes: r.participant_notes,
|
||||||
developerId: r.developer_id,
|
eventType: r.event_type, startTime: r.start_time, endTime: r.end_time,
|
||||||
leadId: r.lead_id,
|
durationMinutes: r.duration_minutes, status: r.status, createdAt: r.created_at,
|
||||||
conversationId: r.conversation_id,
|
|
||||||
title: r.title,
|
|
||||||
description: r.description,
|
|
||||||
participantNotes: r.participant_notes,
|
|
||||||
eventType: r.event_type,
|
|
||||||
startTime: r.start_time,
|
|
||||||
endTime: r.end_time,
|
|
||||||
durationMinutes: r.duration_minutes,
|
|
||||||
status: r.status,
|
|
||||||
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
|
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,
|
developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null,
|
||||||
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
|
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
|
||||||
clientName: r.client_name || null,
|
clientName: r.client_name || null, clientEmail: r.client_email || null, clientPhone: r.client_phone || null,
|
||||||
clientEmail: r.client_email || null,
|
|
||||||
clientPhone: r.client_phone || null,
|
|
||||||
createdAt: r.created_at,
|
|
||||||
},
|
},
|
||||||
}, { status: 201 })
|
}, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
|
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
|
||||||
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
|
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -22,6 +33,7 @@ export async function GET(request: NextRequest) {
|
|||||||
let data: any
|
let data: any
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
|
// Search mode
|
||||||
const url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(q)}&limit=${limit}&offset=${pos}&rating=g`
|
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) })
|
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -31,6 +43,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
data = await res.json()
|
data = await res.json()
|
||||||
} else {
|
} 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 trendingUrl = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${pos}&rating=g`
|
||||||
const trendingRes = await fetch(trendingUrl, { signal: AbortSignal.timeout(8000) })
|
const trendingRes = await fetch(trendingUrl, { signal: AbortSignal.timeout(8000) })
|
||||||
|
|
||||||
@@ -61,6 +74,8 @@ export async function GET(request: NextRequest) {
|
|||||||
data = await fallbackRes.json()
|
data = await fallbackRes.json()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize response to a minimal set of fields
|
||||||
const results = (data.data || []).map((item: any) => {
|
const results = (data.data || []).map((item: any) => {
|
||||||
const dims = item.images?.original
|
const dims = item.images?.original
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser, setSessionContext } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import crypto from "crypto"
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
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) {
|
if (!phone) {
|
||||||
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
|
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(
|
await query(
|
||||||
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
||||||
[token, phone],
|
[token, phone],
|
||||||
@@ -19,7 +53,8 @@ export async function POST(request: NextRequest) {
|
|||||||
const inviteUrl = `${origin}/join/${token}`
|
const inviteUrl = `${origin}/join/${token}`
|
||||||
|
|
||||||
return NextResponse.json({ token, inviteUrl })
|
return NextResponse.json({ token, inviteUrl })
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("Invite generation error:", error)
|
||||||
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
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> {
|
async function checkLeadAccess(leadId: string, userId: string): Promise<boolean> {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT 1 FROM leads WHERE id = $1 AND deleted_at IS NULL
|
`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
|
return result.rows.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
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 }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
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 limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
|
||||||
|
// Fetch notes with author info, ordered newest-first
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT cn.id, cn.created_at, cn.updated_at, cn.content,
|
`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
|
u.id AS user_id, u.first_name, u.last_name, u.avatar_url
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the DB stage name to the client-facing status string.
|
||||||
|
*/
|
||||||
function stageToStatus(name: string): string {
|
function stageToStatus(name: string): string {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "New": return "open"
|
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 }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -25,6 +38,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
const { id } = await params
|
const { id } = await params
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
|
||||||
|
// Fetch lead with stage + user JOINs; enforce ownership scoping
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
`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,
|
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 {
|
function statusToStageName(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "open": return "New"
|
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 }> }) {
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -89,7 +111,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
const { id } = await params
|
const { id } = await params
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
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(
|
const accessCheck = await query(
|
||||||
`SELECT id FROM leads WHERE id = $1 AND deleted_at IS NULL
|
`SELECT id FROM leads WHERE id = $1 AND deleted_at IS NULL
|
||||||
AND ($2 = true OR assigned_to = $3)`,
|
AND ($2 = true OR assigned_to = $3)`,
|
||||||
@@ -104,6 +126,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
const values: any[] = []
|
const values: any[] = []
|
||||||
let idx = 1
|
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.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.contactName !== undefined) { fields.push(`contact_name = $${idx++}`); values.push(body.contactName) }
|
||||||
if (body.email !== undefined) { fields.push(`email = $${idx++}`); values.push(body.email) }
|
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.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.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
|
||||||
if (body.assignedUserId !== undefined) {
|
if (body.assignedUserId !== undefined) {
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
// Only admins can reassign leads
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
// non-admin cannot reassign
|
|
||||||
return NextResponse.json({ error: "Only admins can reassign leads" }, { status: 403 })
|
return NextResponse.json({ error: "Only admins can reassign leads" }, { status: 403 })
|
||||||
}
|
}
|
||||||
fields.push(`assigned_to = $${idx++}`)
|
fields.push(`assigned_to = $${idx++}`)
|
||||||
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
|
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
|
||||||
}
|
}
|
||||||
if (body.status !== undefined) {
|
if (body.status !== undefined) {
|
||||||
|
// Resolve the client status → stage ID via the lookup table
|
||||||
const stageName = statusToStageName(body.status)
|
const stageName = statusToStageName(body.status)
|
||||||
const stageResult = await query("SELECT id FROM lead_stages WHERE name = $1", [stageName])
|
const stageResult = await query("SELECT id FROM lead_stages WHERE name = $1", [stageName])
|
||||||
if (stageResult.rows.length > 0) {
|
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 })
|
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always bump the updated_at timestamp
|
||||||
fields.push(`updated_at = NOW()`)
|
fields.push(`updated_at = NOW()`)
|
||||||
values.push(id)
|
values.push(id)
|
||||||
|
|
||||||
|
|||||||
+93
-39
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
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 {
|
function stageToStatus(name: string): string {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "New": return "open"
|
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 {
|
function getPeriodDateRange(period: string): { start: Date; end: Date } | null {
|
||||||
if (period === "all") return null
|
if (period === "all") return null
|
||||||
const end = new Date()
|
const end = new Date()
|
||||||
@@ -30,6 +47,8 @@ function getPeriodDateRange(period: string): { start: Date; end: Date } | null {
|
|||||||
return { start, end }
|
return { start, end }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -43,47 +62,80 @@ export async function GET(request: NextRequest) {
|
|||||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
|
||||||
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
const params: any[] = []
|
||||||
|
let paramIdx = 1
|
||||||
|
|
||||||
|
// Base condition: exclude soft-deleted leads
|
||||||
|
const conditions: string[] = ["l.deleted_at IS NULL"]
|
||||||
|
|
||||||
|
// Apply date range filter if period is specified
|
||||||
|
if (period !== "all") {
|
||||||
|
const range = getPeriodDateRange(period)
|
||||||
|
if (range) {
|
||||||
|
conditions.push(`l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`)
|
||||||
|
params.push(range.start.toISOString(), range.end.toISOString())
|
||||||
|
paramIdx += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply free-text search across name, company, email, phone (case-insensitive)
|
||||||
|
if (search) {
|
||||||
|
conditions.push(`(l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`)
|
||||||
|
params.push(`%${search}%`)
|
||||||
|
paramIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-admin users can only see their own assigned leads
|
||||||
|
if (!isAdmin) {
|
||||||
|
conditions.push(`l.assigned_to = $${paramIdx}`)
|
||||||
|
params.push(user.id)
|
||||||
|
paramIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map client-facing status → DB stage names for server-side filtering
|
||||||
|
// This avoids pulling all rows and filtering client-side after pagination
|
||||||
|
const statusStageMap: Record<string, string[]> = {
|
||||||
|
open: ["New"],
|
||||||
|
contacted: ["Contacted"],
|
||||||
|
pending: ["Qualified", "Interested", "Demo Scheduled", "Negotiation"],
|
||||||
|
closed: ["Closed Won"],
|
||||||
|
ignored: ["Closed Lost"],
|
||||||
|
}
|
||||||
|
if (status !== "all") {
|
||||||
|
const stageNames = statusStageMap[status]
|
||||||
|
if (stageNames) {
|
||||||
|
const stagePlaceholders = stageNames.map((_, i) => `$${paramIdx + i}`)
|
||||||
|
conditions.push(`ls.name IN (${stagePlaceholders.join(", ")})`)
|
||||||
|
params.push(...stageNames)
|
||||||
|
paramIdx += stageNames.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(" AND ")
|
||||||
|
|
||||||
|
// Total count query (same filters, no pagination)
|
||||||
|
const countResult = await query(
|
||||||
|
`SELECT COUNT(*) AS total FROM leads l JOIN lead_stages ls ON ls.id = l.stage_id WHERE ${whereClause}`,
|
||||||
|
params.slice(0, paramIdx - 1),
|
||||||
|
)
|
||||||
|
const total = parseInt(countResult.rows[0]?.total || "0", 10)
|
||||||
|
|
||||||
|
// Paginated data query with JOINs for stage name and assigned user
|
||||||
|
const dataSql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||||
ls.name AS stage_name,
|
ls.name AS stage_name,
|
||||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||||
FROM leads l
|
FROM leads l
|
||||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||||
LEFT JOIN users u ON u.id = l.assigned_to
|
LEFT JOIN users u ON u.id = l.assigned_to
|
||||||
WHERE l.deleted_at IS NULL`
|
WHERE ${whereClause}
|
||||||
|
ORDER BY l.created_at DESC
|
||||||
|
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
|
||||||
|
const dataParams = [...params, limit, offset]
|
||||||
|
const result = await query(dataSql, dataParams)
|
||||||
|
|
||||||
const params: any[] = []
|
// Shape rows into camelCase API response with denormalized assigned user
|
||||||
let paramIdx = 1
|
const leads = result.rows.map((r: any) => {
|
||||||
|
|
||||||
if (period !== "all") {
|
|
||||||
const range = getPeriodDateRange(period)
|
|
||||||
if (range) {
|
|
||||||
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
|
|
||||||
params.push(range.start.toISOString(), range.end.toISOString())
|
|
||||||
paramIdx += 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
sql += ` AND (l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`
|
|
||||||
params.push(`%${search}%`)
|
|
||||||
paramIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
sql += ` AND l.assigned_to = $${paramIdx}`
|
|
||||||
params.push(user.id)
|
|
||||||
paramIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
sql += ` ORDER BY l.created_at DESC`
|
|
||||||
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
|
|
||||||
params.push(limit, offset)
|
|
||||||
paramIdx += 2
|
|
||||||
|
|
||||||
const result = await query(sql, params)
|
|
||||||
|
|
||||||
let leads = result.rows.map((r: any) => {
|
|
||||||
const s = stageToStatus(r.stage_name)
|
const s = stageToStatus(r.stage_name)
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -106,17 +158,15 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (status !== "all") {
|
return NextResponse.json({ leads, total })
|
||||||
leads = leads.filter((l: any) => l.status === status)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(leads)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Leads API error:", error)
|
console.error("Leads API error:", error)
|
||||||
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -133,6 +183,7 @@ export async function POST(request: NextRequest) {
|
|||||||
assignedUserId = null
|
assignedUserId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Look up the stage ID from the status name (default to "New" for open)
|
||||||
const stageResult = await query(
|
const stageResult = await query(
|
||||||
"SELECT id FROM lead_stages WHERE name = $1",
|
"SELECT id FROM lead_stages WHERE name = $1",
|
||||||
[body.status === "open" ? "New" : "Contacted"]
|
[body.status === "open" ? "New" : "Contacted"]
|
||||||
@@ -162,6 +213,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── DELETE ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -173,6 +226,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const id = searchParams.get("id")
|
const id = searchParams.get("id")
|
||||||
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
|
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(
|
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",
|
"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]
|
[id, isAdmin, user.id]
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── PATCH ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
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 }> }) {
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -14,6 +23,7 @@ export async function GET() {
|
|||||||
[user.id],
|
[user.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Return defaults if no row exists yet
|
||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
leadAssigned: true,
|
leadAssigned: true,
|
||||||
@@ -38,6 +48,8 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PATCH ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -45,6 +57,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
|
// Upsert: create row if not exists, otherwise update
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO notification_preferences (user_id, lead_assigned, lead_status, note_added, daily_digest, weekly_report, updated_at)
|
`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())
|
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -28,6 +37,7 @@ export async function GET() {
|
|||||||
contextType: r.context_type,
|
contextType: r.context_type,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Also return total unread count for badge display
|
||||||
const unreadResult = await query(
|
const unreadResult = await query(
|
||||||
`SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND is_read = FALSE`,
|
`SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND is_read = FALSE`,
|
||||||
[user.id],
|
[user.id],
|
||||||
@@ -43,6 +53,8 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -50,6 +62,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const { type, title, description, link, userId } = await request.json()
|
const { type, title, description, link, userId } = await request.json()
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
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 targetUserId = (userId && isAdmin) ? userId : user.id
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
@@ -75,11 +88,14 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PATCH ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function PATCH() {
|
export async function PATCH() {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
// Mark all unread notifications as read for the current user
|
||||||
await query(
|
await query(
|
||||||
`UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND is_read = FALSE`,
|
`UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND is_read = FALSE`,
|
||||||
[user.id],
|
[user.id],
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -36,6 +45,8 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PATCH ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -46,6 +57,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
|
// Attempt update on existing row; if no row exists, insert one
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`UPDATE company_settings SET
|
`UPDATE company_settings SET
|
||||||
company_name = $1, company_email = $2, company_phone = $3,
|
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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── PATCH ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -16,6 +24,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
const values: any[] = []
|
const values: any[] = []
|
||||||
let idx = 1
|
let idx = 1
|
||||||
|
|
||||||
|
// Build dynamic SET clause for partial updates
|
||||||
if (body.isActive !== undefined) {
|
if (body.isActive !== undefined) {
|
||||||
fields.push(`is_active = $${idx++}`)
|
fields.push(`is_active = $${idx++}`)
|
||||||
values.push(body.isActive)
|
values.push(body.isActive)
|
||||||
@@ -25,12 +34,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
values.push(body.label.trim())
|
values.push(body.label.trim())
|
||||||
}
|
}
|
||||||
if (body.profilePath !== undefined) {
|
if (body.profilePath !== undefined) {
|
||||||
|
// Update both profile_path and the derived cookie_file path
|
||||||
fields.push(`profile_path = $${idx++}, cookie_file = $${idx}`)
|
fields.push(`profile_path = $${idx++}, cookie_file = $${idx}`)
|
||||||
values.push(body.profilePath.trim())
|
values.push(body.profilePath.trim())
|
||||||
values.push(`${body.profilePath.replace(/\\+$/, '')}\\cookies.sqlite`)
|
values.push(`${body.profilePath.replace(/\\+$/, '')}\\cookies.sqlite`)
|
||||||
idx++
|
idx++
|
||||||
}
|
}
|
||||||
if (body.unflag === true) {
|
if (body.unflag === true) {
|
||||||
|
// Clear flag status and reset failure counter
|
||||||
fields.push(`flagged = FALSE, flagged_at = NULL, flagged_reason = NULL, consecutive_failures = 0`)
|
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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -10,6 +18,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch accounts with the latest scrape log result via LATERAL join
|
||||||
const accounts = await query(
|
const accounts = await query(
|
||||||
`SELECT fa.id, fa.label, fa.profile_path, fa.is_active,
|
`SELECT fa.id, fa.label, fa.profile_path, fa.is_active,
|
||||||
fa.last_scrape_at, fa.last_success_at, fa.last_error_at,
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
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 })
|
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 cookieFile = `${profilePath.replace(/\\+$/, '')}\\cookies.sqlite`
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO facebook_accounts (label, profile_path, cookie_file)
|
`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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -32,6 +41,8 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PATCH ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -39,6 +50,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
|
// Upsert: create if not exists, update if it does
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO user_preferences (user_id, timezone, date_format, items_per_page, updated_at)
|
`INSERT INTO user_preferences (user_id, timezone, date_format, items_per_page, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, NOW())
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
|
// ── 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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
// Extract website_theme and color_theme from the JSONB preferences column
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_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],
|
[user.id],
|
||||||
@@ -21,6 +32,8 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PUT ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -35,6 +48,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
update.color_theme = body.colorTheme || "default"
|
update.color_theme = body.colorTheme || "default"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge into the existing JSONB preferences using || operator
|
||||||
if (Object.keys(update).length > 0) {
|
if (Object.keys(update).length > 0) {
|
||||||
await query(
|
await query(
|
||||||
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
||||||
@@ -42,6 +56,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return the updated values
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_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],
|
[user.id],
|
||||||
|
|||||||
@@ -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 { NextResponse } from "next/server"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
// Module-level state for CPU delta calculation
|
||||||
let prevCpu = process.cpuUsage()
|
let prevCpu = process.cpuUsage()
|
||||||
let prevTime = Date.now()
|
let prevTime = Date.now()
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
@@ -17,7 +27,7 @@ export async function GET() {
|
|||||||
const sys = currentCpu.system - prevCpu.system
|
const sys = currentCpu.system - prevCpu.system
|
||||||
const totalUs = user + sys
|
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
|
const cpuPct = elapsed > 0 ? Math.round((totalUs / 1000) / elapsed * 100 * 10) / 10 : 0
|
||||||
|
|
||||||
prevCpu = currentCpu
|
prevCpu = currentCpu
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { writeFile } from "node:fs/promises"
|
import { writeFile } from "node:fs/promises"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import crypto from "node:crypto"
|
import crypto from "node:crypto"
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -15,6 +25,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const duration = parseFloat(formData.get("duration")?.toString() || "0")
|
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 ext = file.name.endsWith(".webm") ? "webm" : "webm"
|
||||||
const filename = `${crypto.randomUUID()}.${ext}`
|
const filename = `${crypto.randomUUID()}.${ext}`
|
||||||
const buffer = Buffer.from(await file.arrayBuffer())
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
// ── DELETE ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
@@ -14,10 +21,12 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
|
|||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
|
// Prevent self-deletion to avoid admin lockout
|
||||||
if (id === sessionUser.id) {
|
if (id === sessionUser.id) {
|
||||||
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 })
|
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Soft-delete: set deleted_at rather than removing the row
|
||||||
await query(
|
await query(
|
||||||
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
|
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
[id]
|
[id]
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"]
|
const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"]
|
||||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB
|
const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB
|
||||||
|
|
||||||
|
// ── POST ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -15,18 +25,20 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
|
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))
|
const allowed = ALLOWED_PREFIXES.some((p) => avatar.startsWith(p))
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
return NextResponse.json({ error: "Avatar must be a PNG, JPEG, or GIF data URL" }, { status: 400 })
|
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 base64Data = avatar.split(",")[1] || ""
|
||||||
const estimatedBytes = Math.round(base64Data.length * 0.75)
|
const estimatedBytes = Math.round(base64Data.length * 0.75)
|
||||||
if (estimatedBytes > MAX_AVATAR_BYTES) {
|
if (estimatedBytes > MAX_AVATAR_BYTES) {
|
||||||
return NextResponse.json({ error: "Avatar exceeds 2MB size limit" }, { status: 400 })
|
return NextResponse.json({ error: "Avatar exceeds 2MB size limit" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the full data URL in the avatar_url column
|
||||||
await query(
|
await query(
|
||||||
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
||||||
[avatar, user.id],
|
[avatar, user.id],
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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")
|
const phone = request.nextUrl.searchParams.get("phone")
|
||||||
if (!phone) {
|
if (!phone) {
|
||||||
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
|
import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
@@ -15,6 +23,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
|
||||||
|
// Fetch users with their role name, excluding soft-deleted records
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
||||||
u.is_active AS active, u.created_at, u.avatar_url,
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
@@ -63,6 +74,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive first/last name from full name string
|
||||||
const nameParts = name.trim().split(/\s+/)
|
const nameParts = name.trim().split(/\s+/)
|
||||||
const firstName = nameParts[0]
|
const firstName = nameParts[0]
|
||||||
const lastName = nameParts.slice(1).join(" ") || firstName
|
const lastName = nameParts.slice(1).join(" ") || firstName
|
||||||
@@ -72,6 +84,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
await setSessionContext(sessionUser.id)
|
await setSessionContext(sessionUser.id)
|
||||||
|
|
||||||
|
// Insert user record with both hash (for login) and encrypted (for recovery)
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by)
|
`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)
|
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]
|
[username.toLowerCase(), email.toLowerCase(), passwordHash, passwordEncrypted, firstName, lastName, active ?? true, sessionUser.id]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Assign the requested role via the user_roles junction table
|
||||||
const roleId = (
|
const roleId = (
|
||||||
await query(`SELECT id FROM roles WHERE LOWER(name) = LOWER($1)`, [role])
|
await query(`SELECT id FROM roles WHERE LOWER(name) = LOWER($1)`, [role])
|
||||||
).rows[0]?.id
|
).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 })
|
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error creating user:", error)
|
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") {
|
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 })
|
return NextResponse.json({ error: "A user with this email or username already exists" }, { status: 409 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const currentUser = await getSessionUser()
|
const currentUser = await getSessionUser()
|
||||||
@@ -17,6 +25,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ users: [] })
|
return NextResponse.json({ users: [] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Case-insensitive search on full name and email; exclude current user
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||||
FROM users
|
FROM users
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, use, useCallback } from "react"
|
import { useState, useEffect, use, useCallback } from "react"
|
||||||
import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, Users, Loader2 } from "lucide-react"
|
import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, Users, Loader2 } from "lucide-react"
|
||||||
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
|
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 }> }) {
|
export default function CallRoomPage({ params }: { params: Promise<{ roomId: string }> }) {
|
||||||
const { roomId } = use(params)
|
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 [micError, setMicError] = useState<string | null>(null)
|
||||||
const [connectionTimeout, setConnectionTimeout] = useState(false)
|
const [connectionTimeout, setConnectionTimeout] = useState(false)
|
||||||
|
|
||||||
|
// ── 10-second connection timeout detection ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isReady) return
|
if (isReady) return
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -35,6 +60,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [isReady])
|
}, [isReady])
|
||||||
|
|
||||||
|
// ── Join handler: requests mic, then joins room ──
|
||||||
const handleJoin = useCallback(async () => {
|
const handleJoin = useCallback(async () => {
|
||||||
if (!displayName.trim()) return
|
if (!displayName.trim()) return
|
||||||
setMicError(null)
|
setMicError(null)
|
||||||
@@ -51,15 +77,18 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
endCall()
|
endCall()
|
||||||
}, [endCall])
|
}, [endCall])
|
||||||
|
|
||||||
|
// ── Pre-join UI (not yet in the room) ──
|
||||||
if (!joined) {
|
if (!joined) {
|
||||||
return (
|
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="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">
|
<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 ? (
|
{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>
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">This call is no longer available.</h1>
|
||||||
</>
|
</>
|
||||||
) : !isReady ? (
|
) : !isReady ? (
|
||||||
|
// Still connecting to signaling server
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-center gap-4 py-6">
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-[#C84B4B]" />
|
<Loader2 className="h-8 w-8 animate-spin text-[#C84B4B]" />
|
||||||
@@ -67,6 +96,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
// Name entry form
|
||||||
<>
|
<>
|
||||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">Join Call</h1>
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">Join Call</h1>
|
||||||
{micError && (
|
{micError && (
|
||||||
@@ -95,9 +125,11 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── In-call UI (joined) ──
|
||||||
return (
|
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="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">
|
<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" && (
|
{callState === "calling" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Calling</h1>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Waiting for other participant ── */}
|
||||||
{callState === "waiting" && (
|
{callState === "waiting" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Waiting for participant</h1>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Participant joined — transitioning to connect ── */}
|
||||||
{callState === "participant_joined" && (
|
{callState === "participant_joined" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Participant joined</h1>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Establishing WebRTC connection ── */}
|
||||||
{callState === "connecting" && (
|
{callState === "connecting" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Connecting</h1>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Active call with controls ── */}
|
||||||
{callState === "connected" && (
|
{callState === "connected" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-1">Connected</h1>
|
<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)}
|
{formatDuration(callDuration)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center gap-4">
|
<div className="flex justify-center gap-4">
|
||||||
|
{/* Speaker toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleSpeaker}
|
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]'}`}
|
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" />}
|
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Mute toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleMute}
|
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]'}`}
|
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" />}
|
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
|
{/* End call */}
|
||||||
<button
|
<button
|
||||||
onClick={handleEnd}
|
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"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Call ended / idle ── */}
|
||||||
{(callState === "ended" || callState === "idle") && (
|
{(callState === "ended" || callState === "idle") && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Call ended</h1>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Error banner ── */}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-[#C84B4B] text-xs mt-4">{error}</p>
|
<p className="text-[#C84B4B] text-xs mt-4">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 { query } from "@/lib/db"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
@@ -5,6 +20,16 @@ interface Props {
|
|||||||
params: Promise<{ token: string }>
|
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 }) {
|
function ErrorPage({ message }: { message: string }) {
|
||||||
return (
|
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="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) {
|
export default async function JoinPage({ params }: Props) {
|
||||||
const { token } = await params
|
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
|
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)) {
|
if (UUID_REGEX.test(token)) {
|
||||||
redirect(`/call/${token}`)
|
redirect(`/call/${token}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Look up SMS invite token in DB ──
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT phone, created_at, expires_at FROM invites WHERE token = $1 LIMIT 1`,
|
`SELECT phone, created_at, expires_at FROM invites WHERE token = $1 LIMIT 1`,
|
||||||
[token],
|
[token],
|
||||||
@@ -37,6 +75,7 @@ export default async function JoinPage({ params }: Props) {
|
|||||||
|
|
||||||
const invite = result.rows[0]
|
const invite = result.rows[0]
|
||||||
|
|
||||||
|
// ── Check expiration ──
|
||||||
if (new Date(invite.expires_at) <= new Date()) {
|
if (new Date(invite.expires_at) <= new Date()) {
|
||||||
return <ErrorPage message="This call has expired." />
|
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">
|
<p className="text-[#8A9078] text-sm mb-6">
|
||||||
Someone wants to connect with you on our platform.
|
Someone wants to connect with you on our platform.
|
||||||
</p>
|
</p>
|
||||||
|
{/* ── Inviter's contact number ── */}
|
||||||
<div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
|
<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-xs text-[#8A9078] mb-1">Contact Number</p>
|
||||||
<p className="text-sm font-medium text-[#2D3020] dark:text-white">{invite.phone}</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">
|
<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.
|
Create an account to start making free voice calls to this person and others on the platform.
|
||||||
</p>
|
</p>
|
||||||
|
{/* ── Registration CTA ── */}
|
||||||
<a
|
<a
|
||||||
href="/register"
|
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"
|
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"
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useRef, Suspense } from "react"
|
import { useState, useEffect, useRef, Suspense } from "react"
|
||||||
import { useSearchParams, useRouter } from "next/navigation"
|
import { useSearchParams, useRouter } from "next/navigation"
|
||||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
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"
|
const WEBSITE_THEME_KEY = "crm-website-theme"
|
||||||
|
|
||||||
|
/** Configuration for the animated sine-wave canvas (amplitude, frequency, speed, vertical offset, fill). */
|
||||||
const waves = [
|
const waves = [
|
||||||
{ a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" },
|
{ 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)" },
|
{ 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)" },
|
{ 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() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={
|
<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() {
|
function LoginForm() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
@@ -39,6 +77,7 @@ function LoginForm() {
|
|||||||
const counterStarted = useRef(false)
|
const counterStarted = useRef(false)
|
||||||
const [testimonialIndex, setTestimonialIndex] = useState(0)
|
const [testimonialIndex, setTestimonialIndex] = useState(0)
|
||||||
|
|
||||||
|
/** Rotating testimonials shown in the left panel. */
|
||||||
const testimonials = [
|
const testimonials = [
|
||||||
{
|
{
|
||||||
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
|
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(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setTestimonialIndex((prev) => (prev + 1) % testimonials.length)
|
setTestimonialIndex((prev) => (prev + 1) % testimonials.length)
|
||||||
}, 5000)
|
}, 5000)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [testimonials.length])
|
}, [testimonials.length])
|
||||||
|
/** Ref for the sine-wave canvas (left panel). */
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
/** Ref for the star-field canvas (right panel). */
|
||||||
const starsCanvasRightRef = useRef<HTMLCanvasElement>(null)
|
const starsCanvasRightRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
|
// ── Animated sine-wave canvas (left panel background) ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
@@ -68,6 +111,7 @@ function LoginForm() {
|
|||||||
let animId: number
|
let animId: number
|
||||||
let time = 0
|
let time = 0
|
||||||
|
|
||||||
|
/** Handle HiDPI (Retina) display scaling. */
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
const parent = canvas.parentElement!
|
const parent = canvas.parentElement!
|
||||||
const rect = parent.getBoundingClientRect()
|
const rect = parent.getBoundingClientRect()
|
||||||
@@ -82,6 +126,7 @@ function LoginForm() {
|
|||||||
resize()
|
resize()
|
||||||
window.addEventListener("resize", resize)
|
window.addEventListener("resize", resize)
|
||||||
|
|
||||||
|
/** Render each wave layer using a sine function. */
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
const w = canvas.width / (window.devicePixelRatio || 1)
|
const w = canvas.width / (window.devicePixelRatio || 1)
|
||||||
const h = 200
|
const h = 200
|
||||||
@@ -112,10 +157,12 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Twinkling star-field canvas (right panel background) ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvases = [starsCanvasRightRef.current].filter(Boolean) as HTMLCanvasElement[]
|
const canvases = [starsCanvasRightRef.current].filter(Boolean) as HTMLCanvasElement[]
|
||||||
if (canvases.length === 0) return
|
if (canvases.length === 0) return
|
||||||
|
|
||||||
|
/** Generate 312 stars with random positions, sizes, opacities, and drift velocities. */
|
||||||
const stars = Array.from({ length: 312 }, () => ({
|
const stars = Array.from({ length: 312 }, () => ({
|
||||||
x: Math.random(),
|
x: Math.random(),
|
||||||
y: Math.random(),
|
y: Math.random(),
|
||||||
@@ -130,6 +177,7 @@ function LoginForm() {
|
|||||||
let animId: number
|
let animId: number
|
||||||
let time = 0
|
let time = 0
|
||||||
|
|
||||||
|
/** Handle HiDPI resize for all star canvases. */
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
for (const c of canvases) {
|
for (const c of canvases) {
|
||||||
const dpr = window.devicePixelRatio || 1
|
const dpr = window.devicePixelRatio || 1
|
||||||
@@ -149,6 +197,7 @@ function LoginForm() {
|
|||||||
})
|
})
|
||||||
window.addEventListener("resize", resize)
|
window.addEventListener("resize", resize)
|
||||||
|
|
||||||
|
/** Render each star with a sinusoidal opacity twinkle and slow parallax drift. */
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
time += 1
|
time += 1
|
||||||
for (const c of canvases) {
|
for (const c of canvases) {
|
||||||
@@ -169,6 +218,7 @@ function LoginForm() {
|
|||||||
ctx.fillStyle = `rgba(255,255,255,${opacity})`
|
ctx.fillStyle = `rgba(255,255,255,${opacity})`
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
|
|
||||||
|
// Brighter stars get a subtle glow halo
|
||||||
if (star.r > 0.5) {
|
if (star.r > 0.5) {
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(x, y, star.r * 1.8, 0, Math.PI * 2)
|
ctx.arc(x, y, star.r * 1.8, 0, Math.PI * 2)
|
||||||
@@ -190,12 +240,14 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Animated stat counters (left panel) ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (counterStarted.current) return
|
if (counterStarted.current) return
|
||||||
counterStarted.current = true
|
counterStarted.current = true
|
||||||
const targets = { leads: 1247, conversion: 40, users: 83 }
|
const targets = { leads: 1247, conversion: 40, users: 83 }
|
||||||
const steps = 150
|
const steps = 150
|
||||||
|
|
||||||
|
// Delayed start so the user sees the page first
|
||||||
const delayTimer = setTimeout(() => {
|
const delayTimer = setTimeout(() => {
|
||||||
let step = 0
|
let step = 0
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -205,6 +257,7 @@ function LoginForm() {
|
|||||||
setCounts(targets)
|
setCounts(targets)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Increment each counter proportionally toward its target
|
||||||
setCounts({
|
setCounts({
|
||||||
leads: Math.min(Math.floor((targets.leads / steps) * step), targets.leads),
|
leads: Math.min(Math.floor((targets.leads / steps) * step), targets.leads),
|
||||||
conversion: Math.min(Math.floor((targets.conversion / steps) * step), targets.conversion),
|
conversion: Math.min(Math.floor((targets.conversion / steps) * step), targets.conversion),
|
||||||
@@ -216,6 +269,7 @@ function LoginForm() {
|
|||||||
return () => clearTimeout(delayTimer)
|
return () => clearTimeout(delayTimer)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Form submission handler ──
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError("")
|
setError("")
|
||||||
@@ -229,9 +283,11 @@ function LoginForm() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
// Clear any persisted theme so dashboard picks up the user's saved preference
|
||||||
localStorage.removeItem(WEBSITE_THEME_KEY)
|
localStorage.removeItem(WEBSITE_THEME_KEY)
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.className = root.className.split(" ").filter((c) => !c.startsWith("theme-")).join(" ").trim()
|
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"
|
const redirectTo = searchParams.get("redirect") || "/dashboard"
|
||||||
router.push(redirectTo)
|
router.push(redirectTo)
|
||||||
} else {
|
} else {
|
||||||
@@ -246,7 +302,9 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// ── Full-page two-panel layout ──
|
||||||
<div className="flex min-h-screen bg-[#0a0a0f]">
|
<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="left-panel flex flex-1 flex-col p-12">
|
||||||
<div className="relative z-10 flex-1 flex items-center justify-center">
|
<div className="relative z-10 flex-1 flex items-center justify-center">
|
||||||
<div className="text-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">
|
<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.
|
Manage leads, track conversions, and grow your web development business with our CRM.
|
||||||
</p>
|
</p>
|
||||||
|
{/* Animated stat counters */}
|
||||||
<div className="stats-row">
|
<div className="stats-row">
|
||||||
<div className="stat">
|
<div className="stat">
|
||||||
<div className="stat-number">{counts.leads.toLocaleString()}</div>
|
<div className="stat-number">{counts.leads.toLocaleString()}</div>
|
||||||
@@ -278,6 +337,7 @@ function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
</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)" }}>
|
<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" }}>
|
<svg width="0" height="0" style={{ position: "absolute" }}>
|
||||||
<defs>
|
<defs>
|
||||||
@@ -286,6 +346,7 @@ function LoginForm() {
|
|||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
{/* Key changes on testimonialIndex to trigger transition */}
|
||||||
<div className="transition-opacity duration-500" key={testimonialIndex}>
|
<div className="transition-opacity duration-500" key={testimonialIndex}>
|
||||||
<p className="text-sm font-semibold leading-relaxed" style={{ color: "#0a0a0f" }}>
|
<p className="text-sm font-semibold leading-relaxed" style={{ color: "#0a0a0f" }}>
|
||||||
“{testimonials[testimonialIndex].text}”
|
“{testimonials[testimonialIndex].text}”
|
||||||
@@ -294,6 +355,7 @@ function LoginForm() {
|
|||||||
{testimonials[testimonialIndex].author}
|
{testimonials[testimonialIndex].author}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Dot indicators for manual testimonial selection */}
|
||||||
<div className="flex items-center justify-center gap-1.5 mt-3">
|
<div className="flex items-center justify-center gap-1.5 mt-3">
|
||||||
{testimonials.map((_, i) => (
|
{testimonials.map((_, i) => (
|
||||||
<button
|
<button
|
||||||
@@ -311,6 +373,7 @@ function LoginForm() {
|
|||||||
|
|
||||||
<div className="panel-divider" />
|
<div className="panel-divider" />
|
||||||
|
|
||||||
|
{/* ── RIGHT PANEL: Login form ── */}
|
||||||
<div className="right-panel flex items-center justify-center p-10">
|
<div className="right-panel flex items-center justify-center p-10">
|
||||||
<canvas ref={starsCanvasRightRef} className="stars-canvas" />
|
<canvas ref={starsCanvasRightRef} className="stars-canvas" />
|
||||||
<div className="accent-line" />
|
<div className="accent-line" />
|
||||||
@@ -323,6 +386,7 @@ function LoginForm() {
|
|||||||
Sign in to your account to continue
|
Sign in to your account to continue
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Error alert banner */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
|
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
|
||||||
{error}
|
{error}
|
||||||
@@ -330,6 +394,7 @@ function LoginForm() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{/* Email field */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="login-label">
|
<label htmlFor="email" className="login-label">
|
||||||
Email
|
Email
|
||||||
@@ -348,6 +413,7 @@ function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Password field with show/hide toggle and forgot link */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-[6px]">
|
<div className="flex items-center justify-between mb-[6px]">
|
||||||
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
|
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
|
||||||
@@ -384,6 +450,7 @@ function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Remember me checkbox */}
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -396,12 +463,14 @@ function LoginForm() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* Submit button with loading state */}
|
||||||
<button type="submit" className="auth-btn" disabled={loading}>
|
<button type="submit" className="auth-btn" disabled={loading}>
|
||||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{loading ? "Signing in..." : "Sign in"}
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Legal footer links */}
|
||||||
<p className="text-center text-[11px] mt-4 footer-text">
|
<p className="text-center text-[11px] mt-4 footer-text">
|
||||||
By signing in, you agree to our{" "}
|
By signing in, you agree to our{" "}
|
||||||
<button type="button" className="text-[#1BB0CE] hover:underline">
|
<button type="button" className="text-[#1BB0CE] hover:underline">
|
||||||
|
|||||||
@@ -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"
|
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() {
|
export default function RootPage() {
|
||||||
redirect("/dashboard")
|
redirect("/dashboard")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
|
import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
|
||||||
import { Bot, Terminal } from "lucide-react"
|
import { Bot, Terminal } from "lucide-react"
|
||||||
import { GifPicker } from "./gif-picker"
|
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) {
|
function linkifyText(text: string) {
|
||||||
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
||||||
const parts = text.split(urlRegex)
|
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) {
|
function formatContent(text: string) {
|
||||||
const lines = text.split("\n")
|
const lines = text.split("\n")
|
||||||
return lines.map((line, i) => {
|
return lines.map((line, i) => {
|
||||||
@@ -33,6 +45,7 @@ function formatContent(text: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single chat message, either from the user or the AI assistant */
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
content: string
|
content: string
|
||||||
@@ -45,16 +58,24 @@ interface ChatMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AIChatProps {
|
interface AIChatProps {
|
||||||
|
/** Called when the user sends a message */
|
||||||
onMessageSent?: (msg: string) => void
|
onMessageSent?: (msg: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Exposed imperative methods for parent components */
|
||||||
export interface AIChatHandle {
|
export interface AIChatHandle {
|
||||||
fillInput: (text: string) => void
|
fillInput: (text: string) => void
|
||||||
addAssistantMessage: (content: 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) => {
|
export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent }, ref) => {
|
||||||
|
// ── State & refs ──
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -67,6 +88,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
const hasUserMessage = messages.some(m => m.role === "user")
|
const hasUserMessage = messages.some(m => m.role === "user")
|
||||||
const loadedFromStorage = useRef(false)
|
const loadedFromStorage = useRef(false)
|
||||||
|
|
||||||
|
// ── Expose imperative methods to parent via ref ──
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
fillInput(text: string) {
|
fillInput(text: string) {
|
||||||
setInput(text)
|
setInput(text)
|
||||||
@@ -85,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 }) => {
|
const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => {
|
||||||
setMessages((prev) => [...prev, { role: "user", content: gif.title || "Sent a GIF", gif }])
|
setMessages((prev) => [...prev, { role: "user", content: gif.title || "Sent a GIF", gif }])
|
||||||
setShowGifPicker(false)
|
setShowGifPicker(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Close GIF picker on outside click ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showGifPicker) {
|
if (showGifPicker) {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -102,6 +126,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
}
|
}
|
||||||
}, [showGifPicker])
|
}, [showGifPicker])
|
||||||
|
|
||||||
|
// ── Load messages from localStorage, filtering out messages older than 1 week ──
|
||||||
const _WEEK_MS = 604800000
|
const _WEEK_MS = 604800000
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -123,6 +148,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Persist messages to localStorage whenever they change ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -130,6 +156,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
|
// ── Poll the AI server until it is no longer returning 503 (booting) ──
|
||||||
const checkServer = useCallback(async () => {
|
const checkServer = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/ai/chat", {
|
const res = await fetch("/api/ai/chat", {
|
||||||
@@ -147,6 +174,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── On mount: poll server, load jobs to build welcome message ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkServer()
|
checkServer()
|
||||||
if (loadedFromStorage.current) return
|
if (loadedFromStorage.current) return
|
||||||
@@ -180,10 +208,12 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
})
|
})
|
||||||
}, [checkServer])
|
}, [checkServer])
|
||||||
|
|
||||||
|
// ── Auto-scroll to bottom when new messages arrive ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
|
// ── Send a message to the AI API ──
|
||||||
const sendMessage = useCallback(async (text?: string) => {
|
const sendMessage = useCallback(async (text?: string) => {
|
||||||
const msg = (text || input).trim()
|
const msg = (text || input).trim()
|
||||||
if (!msg || loading) return
|
if (!msg || loading) return
|
||||||
@@ -220,6 +250,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
}
|
}
|
||||||
}, [input, loading, onMessageSent])
|
}, [input, loading, onMessageSent])
|
||||||
|
|
||||||
|
// ── Enter key submits; Shift+Enter inserts newline ──
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -227,12 +258,14 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
}
|
}
|
||||||
}, [sendMessage])
|
}, [sendMessage])
|
||||||
|
|
||||||
|
// ── Auto-resize textarea on input ──
|
||||||
const handleTextareaInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
|
const handleTextareaInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||||
const t = e.target as HTMLTextAreaElement
|
const t = e.target as HTMLTextAreaElement
|
||||||
t.style.height = "auto"
|
t.style.height = "auto"
|
||||||
t.style.height = t.scrollHeight + "px"
|
t.style.height = t.scrollHeight + "px"
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Boot screen ──
|
||||||
if (bootState === "booting") {
|
if (bootState === "booting") {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
@@ -259,6 +292,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Welcome screen (ready, no user messages yet) ──
|
||||||
if (bootState === "ready" && !hasUserMessage) {
|
if (bootState === "ready" && !hasUserMessage) {
|
||||||
return (
|
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" }}>
|
<div className="flex-1 flex flex-col overflow-y-auto" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
|
||||||
@@ -273,6 +307,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* ── Input area (sticky bottom) ── */}
|
||||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
|
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
|
||||||
<div className="max-w-3xl mx-auto">
|
<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}>
|
<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}>
|
||||||
@@ -307,13 +342,16 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Chat view (messages present) ──
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<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="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">
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
{msg.role === "assistant" ? (
|
{msg.role === "assistant" ? (
|
||||||
|
/* ── AI assistant bubble (left-aligned) ── */
|
||||||
<div className="flex gap-3 items-start">
|
<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">
|
<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" />
|
<Bot className="h-4 w-4" />
|
||||||
@@ -326,6 +364,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
/* ── User bubble (right-aligned) ── */
|
||||||
<div className="flex gap-3 items-start flex-row-reverse">
|
<div className="flex gap-3 items-start flex-row-reverse">
|
||||||
<div className="flex-1 min-w-0 flex justify-end">
|
<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%]">
|
<div className="bg-gradient-to-br from-primary to-primary rounded-2xl rounded-tr-sm px-5 py-4 max-w-[75%]">
|
||||||
@@ -345,6 +384,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{/* ── Typing indicator (bouncing dots) ── */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex gap-3 items-start">
|
<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">
|
<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">
|
||||||
@@ -360,6 +400,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* ── Input area (sticky bottom) ── */}
|
||||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
|
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react"
|
import { useState, useRef, useEffect, useCallback } from "react"
|
||||||
import { Search, Loader2, ImageIcon } from "lucide-react"
|
import { Search, Loader2, ImageIcon } from "lucide-react"
|
||||||
|
|
||||||
|
/** A single GIF result from the GIPHY API */
|
||||||
interface GifResult {
|
interface GifResult {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -14,12 +19,19 @@ interface GifResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface GifPickerProps {
|
interface GifPickerProps {
|
||||||
|
/** Called when a GIF is selected */
|
||||||
onSelect: (gif: { url: string; previewUrl: string; title: string }) => void
|
onSelect: (gif: { url: string; previewUrl: string; title: string }) => void
|
||||||
|
/** Called to close the picker */
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** In-memory cache keyed by search query or "__trending__" */
|
||||||
const SEARCH_CACHE = new Map<string, GifResult[]>()
|
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) {
|
export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
||||||
const [gifs, setGifs] = useState<GifResult[]>([])
|
const [gifs, setGifs] = useState<GifResult[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -32,6 +44,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
|||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
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) => {
|
const fetchGifs = useCallback(async (q: string, off: number, append: boolean) => {
|
||||||
if (off === 0) {
|
if (off === 0) {
|
||||||
const cacheKey = q || "__trending__"
|
const cacheKey = q || "__trending__"
|
||||||
@@ -45,6 +58,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Perform the API request ──
|
||||||
try {
|
try {
|
||||||
if (!append) setLoading(true)
|
if (!append) setLoading(true)
|
||||||
else setLoadingMore(true)
|
else setLoadingMore(true)
|
||||||
@@ -67,6 +81,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const newGifs: GifResult[] = data.gifs || []
|
const newGifs: GifResult[] = data.gifs || []
|
||||||
|
|
||||||
|
// Append or replace results, caching on first page
|
||||||
if (append) {
|
if (append) {
|
||||||
setGifs((prev) => [...prev, ...newGifs])
|
setGifs((prev) => [...prev, ...newGifs])
|
||||||
} else {
|
} else {
|
||||||
@@ -89,14 +104,17 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Load trending GIFs on mount ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchGifs("", 0, false)
|
fetchGifs("", 0, false)
|
||||||
}, [fetchGifs])
|
}, [fetchGifs])
|
||||||
|
|
||||||
|
// ── Auto-focus the search input ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Debounced search: 400ms after the user stops typing ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
|
||||||
if (!search.trim()) {
|
if (!search.trim()) {
|
||||||
@@ -111,6 +129,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
|||||||
}
|
}
|
||||||
}, [search, fetchGifs])
|
}, [search, fetchGifs])
|
||||||
|
|
||||||
|
// ── Infinite scroll via IntersectionObserver on the sentinel ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sentinel = sentinelRef.current
|
const sentinel = sentinelRef.current
|
||||||
if (!sentinel) return
|
if (!sentinel) return
|
||||||
@@ -129,7 +148,9 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
|||||||
}, [hasMore, loadingMore, loading, offset, search, fetchGifs])
|
}, [hasMore, loadingMore, loading, offset, search, fetchGifs])
|
||||||
|
|
||||||
return (
|
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">
|
<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="p-3 border-b border-border">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── GIF grid with loading / error / empty states ── */}
|
||||||
<div className="overflow-y-auto" style={{ maxHeight: "360px" }}>
|
<div className="overflow-y-auto" style={{ maxHeight: "360px" }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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" />
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Sentinel element for infinite scroll detection */}
|
||||||
{hasMore && <div ref={sentinelRef} className="h-4" />}
|
{hasMore && <div ref={sentinelRef} className="h-4" />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Briefcase, ChevronDown, Loader2, Search } from "lucide-react"
|
import { Briefcase, ChevronDown, Loader2, Search } from "lucide-react"
|
||||||
|
|
||||||
|
/** A job category returned from the API */
|
||||||
interface Job {
|
interface Job {
|
||||||
job_title: string
|
job_title: string
|
||||||
keywords: string[]
|
keywords: string[]
|
||||||
@@ -11,17 +16,25 @@ interface Job {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface JobSelectorProps {
|
interface JobSelectorProps {
|
||||||
|
/** Called when a job is selected or cleared (null) */
|
||||||
onSelect: (job: Job | null) => void
|
onSelect: (job: Job | null) => void
|
||||||
|
/** Called when the user clicks the "Search Facebook" button */
|
||||||
onSearch?: (job: Job) => void
|
onSearch?: (job: Job) => void
|
||||||
|
/** Whether a search is currently in progress */
|
||||||
searching?: boolean
|
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) {
|
export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps) {
|
||||||
const [jobs, setJobs] = useState<Job[]>([])
|
const [jobs, setJobs] = useState<Job[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [selected, setSelected] = useState<Job | null>(null)
|
const [selected, setSelected] = useState<Job | null>(null)
|
||||||
|
|
||||||
|
// ── Fetch available job categories on mount ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/ai/jobs")
|
fetch("/api/ai/jobs")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -30,6 +43,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Select a job, close dropdown, notify parent ──
|
||||||
const handleSelect = (job: Job) => {
|
const handleSelect = (job: Job) => {
|
||||||
setSelected(job)
|
setSelected(job)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@@ -38,6 +52,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{/* ── Dropdown trigger button ── */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
@@ -50,8 +65,10 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
|||||||
{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" : ""}`} />}
|
{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>
|
</button>
|
||||||
|
|
||||||
|
{/* ── Dropdown menu — visible when open ── */}
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
|
{/* Backdrop to close on click outside */}
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
<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-card border border-border rounded-xl shadow-xl 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) => (
|
{jobs.map((job, i) => (
|
||||||
@@ -59,10 +76,10 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
|||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelect(job)}
|
onClick={() => handleSelect(job)}
|
||||||
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-0 border-l-2 border-l-transparent hover:border-l-primary/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="font-medium">{job.job_title}</div>
|
||||||
<div className="text-xs text-muted-foreground/60 mt-0.5">{job.industry} — {job.description}</div>
|
<div className="text-xs text-muted-foreground/60 mt-0.5">{job.industry} — {job.description}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{jobs.length === 0 && !loading && (
|
{jobs.length === 0 && !loading && (
|
||||||
@@ -71,12 +88,13 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* ── "Search Facebook" CTA — shown only when a job is selected ── */}
|
||||||
{selected && onSearch && (
|
{selected && onSearch && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSearch(selected)}
|
onClick={() => onSearch(selected)}
|
||||||
disabled={searching}
|
disabled={searching}
|
||||||
className="w-full flex items-center justify-center gap-2 mt-3 bg-gradient-to-r from-[#f97316] to-[#ea580c] hover:from-[#ea580c] hover:to-[#dc2626] disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-xl px-4 py-3 transition-all duration-200 shadow-lg shadow-[#f97316]/20"
|
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 ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||||
{searching ? "Searching..." : "Search Facebook"}
|
{searching ? "Searching..." : "Search Facebook"}
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react"
|
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 data from "@emoji-mart/data"
|
||||||
import Picker from "@emoji-mart/react"
|
import Picker from "@emoji-mart/react"
|
||||||
import { getStickerPacks, type StickerPack } from "@/data/stickers"
|
import { getStickerPacks, type StickerPack } from "@/data/stickers"
|
||||||
|
import { sanitizeSvg } from "@/lib/sanitize"
|
||||||
|
|
||||||
type Tab = "emoji" | "gif" | "sticker"
|
type Tab = "emoji" | "gif" | "sticker"
|
||||||
|
|
||||||
@@ -18,21 +24,29 @@ interface MediaPickerProps {
|
|||||||
theme?: string
|
theme?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** localStorage keys for recent media tracking */
|
||||||
const RECENT_GIFS_KEY = "recent-gifs"
|
const RECENT_GIFS_KEY = "recent-gifs"
|
||||||
const RECENT_STICKERS_KEY = "recent-stickers"
|
const RECENT_STICKERS_KEY = "recent-stickers"
|
||||||
const MAX_RECENT = 12
|
const MAX_RECENT = 12
|
||||||
|
|
||||||
|
/** Retrieve recent items from localStorage */
|
||||||
function getRecent(key: string): string[] {
|
function getRecent(key: string): string[] {
|
||||||
if (typeof window === "undefined") return []
|
if (typeof window === "undefined") return []
|
||||||
try { return JSON.parse(localStorage.getItem(key) || "[]") } catch { 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) {
|
function addRecent(key: string, id: string) {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
const items = [id, ...getRecent(key).filter((x) => x !== id)].slice(0, MAX_RECENT)
|
const items = [id, ...getRecent(key).filter((x) => x !== id)].slice(0, MAX_RECENT)
|
||||||
localStorage.setItem(key, JSON.stringify(items))
|
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) {
|
export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, theme }: MediaPickerProps) {
|
||||||
const [tab, setTab] = useState<Tab>("emoji")
|
const [tab, setTab] = useState<Tab>("emoji")
|
||||||
const [gifQuery, setGifQuery] = useState("")
|
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"
|
className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square"
|
||||||
>
|
>
|
||||||
{sticker.svg ? (
|
{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>
|
<span className="text-5xl">{sticker.emoji}</span>
|
||||||
)}
|
)}
|
||||||
@@ -303,7 +317,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
|||||||
if (!s) return null
|
if (!s) return null
|
||||||
return (
|
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">
|
<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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { useState, useEffect, useCallback } from "react"
|
||||||
@@ -17,6 +21,7 @@ interface VoiceCallModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Converts a phone number to international format suitable for WhatsApp links */
|
||||||
function formatPhoneForWhatsApp(phone: string): string {
|
function formatPhoneForWhatsApp(phone: string): string {
|
||||||
const digits = phone.replace(/[^0-9]/g, "")
|
const digits = phone.replace(/[^0-9]/g, "")
|
||||||
if (!digits) return ""
|
if (!digits) return ""
|
||||||
@@ -25,6 +30,11 @@ function formatPhoneForWhatsApp(phone: string): string {
|
|||||||
return digits
|
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) {
|
export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||||
const [contacts, setContacts] = useState<Contact[]>([])
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
const [contactsLoading, setContactsLoading] = useState(false)
|
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"
|
"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() {
|
export function CrossedLightsabers() {
|
||||||
return (
|
return (
|
||||||
<svg
|
<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"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
@@ -14,11 +19,13 @@ interface LeadStatusChartProps {
|
|||||||
data: StatusData[]
|
data: StatusData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convert polar coordinates to cartesian */
|
||||||
function polar(cx: number, cy: number, r: number, deg: number) {
|
function polar(cx: number, cy: number, r: number, deg: number) {
|
||||||
const rad = ((deg - 90) * Math.PI) / 180
|
const rad = ((deg - 90) * Math.PI) / 180
|
||||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
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) {
|
function arcPath(cx: number, cy: number, oR: number, iR: number, start: number, end: number) {
|
||||||
const gap = 3
|
const gap = 3
|
||||||
const s = start + gap / 2
|
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`
|
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) {
|
export function LeadStatusChart({ data }: LeadStatusChartProps) {
|
||||||
const [hov, setHov] = useState<number | null>(null)
|
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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useMemo, useEffect } from "react"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
|
||||||
|
/** A single month's data point */
|
||||||
interface IntervalData {
|
interface IntervalData {
|
||||||
label: string
|
label: string
|
||||||
leads: number
|
leads: number
|
||||||
@@ -16,9 +21,15 @@ interface LeadsPerMonthChartProps {
|
|||||||
data: IntervalData[]
|
data: IntervalData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Color constants for new leads and closed bars */
|
||||||
const NEW_LEADS = "#C84B4B"
|
const NEW_LEADS = "#C84B4B"
|
||||||
const CLOSED = "#5A8FC4"
|
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) {
|
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
|
||||||
const [year, setYear] = useState(new Date().getFullYear())
|
const [year, setYear] = useState(new Date().getFullYear())
|
||||||
const [chartData, setChartData] = useState<IntervalData[]>(initialData)
|
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"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
@@ -8,6 +12,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|||||||
import { Lead } from "@/types"
|
import { Lead } from "@/types"
|
||||||
import { ArrowRight } from "lucide-react"
|
import { ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
|
/** Color-coded status badge classes */
|
||||||
const statusStyles: Record<string, string> = {
|
const statusStyles: Record<string, string> = {
|
||||||
open: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
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",
|
contacted: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||||
@@ -20,6 +25,10 @@ interface RecentLeadsTableProps {
|
|||||||
leads: Lead[]
|
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) {
|
export function RecentLeadsTable({ leads }: RecentLeadsTableProps) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// ── Dashboard: Star Field Background ──
|
||||||
|
// Decorative fixed-position animated starfield with randomized positions, sizes, and twinkle timings.
|
||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
@@ -10,6 +13,10 @@ interface Star {
|
|||||||
duration: number
|
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() {
|
export function StarField() {
|
||||||
const stars = useMemo<Star[]>(() => {
|
const stars = useMemo<Star[]>(() => {
|
||||||
const result: 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"
|
"use client"
|
||||||
|
|
||||||
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
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() {
|
export function StatCardSkeleton() {
|
||||||
const { websiteTheme } = useWebsiteTheme()
|
const { websiteTheme } = useWebsiteTheme()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
@@ -19,6 +23,7 @@ interface StatCardProps {
|
|||||||
conversionRate?: number
|
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 }> = {
|
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" },
|
"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" },
|
"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" },
|
"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 {
|
function computeGoal(max: number): number {
|
||||||
if (max <= 0) return 100
|
if (max <= 0) return 100
|
||||||
return Math.ceil(max / 100) * 100 || 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 {
|
function smoothPath(points: { x: number; y: number }[]): string {
|
||||||
if (points.length === 0) return ""
|
if (points.length === 0) return ""
|
||||||
if (points.length === 1) return `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`
|
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
|
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) {
|
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
|
||||||
const { websiteTheme } = useWebsiteTheme()
|
const { websiteTheme } = useWebsiteTheme()
|
||||||
const color = cardColors[title] ?? cardColors["Total Leads"]
|
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 [display, setDisplay] = useState(0)
|
||||||
const target = typeof value === "number" ? value : 0
|
const target = typeof value === "number" ? value : 0
|
||||||
|
|
||||||
|
// ── Animated counter: increments from 0 to target over 1 second ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isNumeric) return
|
if (!isNumeric) return
|
||||||
const duration = 1000
|
const duration = 1000
|
||||||
@@ -76,6 +87,7 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [target, isNumeric])
|
}, [target, isNumeric])
|
||||||
|
|
||||||
|
// ── Build sparkline SVG path and grid lines from monthly breakdown data ──
|
||||||
function buildSparklineSvg(): { path: string; area: string; goal: number; gridLines: { y: number }[] } {
|
function buildSparklineSvg(): { path: string; area: string; goal: number; gridLines: { y: number }[] } {
|
||||||
if (!monthlyBreakdown || !sparklineField) return { path: "", area: "", goal: 100, gridLines: [] }
|
if (!monthlyBreakdown || !sparklineField) return { path: "", area: "", goal: 100, gridLines: [] }
|
||||||
const values = monthlyBreakdown.map((m) => m[sparklineField]).reverse()
|
const values = monthlyBreakdown.map((m) => m[sparklineField]).reverse()
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
// ── Layout: App Shell Component ──
|
||||||
|
// Root layout wrapper that combines Sidebar, Topbar, and page content.
|
||||||
|
// Manages sidebar collapse state (persisted to localStorage), mobile sidebar toggle,
|
||||||
|
// Spidey theme background decoration, and page transition animations.
|
||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
@@ -11,23 +16,29 @@ interface AppShellProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppShell — top-level layout component.
|
||||||
|
* Handles sidebar collapsed/expanded state (persisted), mobile sidebar toggle,
|
||||||
|
* Spidey-theme background patterns/stripes, and page-level route transitions.
|
||||||
|
*/
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
const { websiteTheme } = useWebsiteTheme()
|
const { websiteTheme } = useWebsiteTheme()
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
// Close mobile sidebar on route change
|
// ── Close mobile sidebar on route change ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMobileOpen(false)
|
setMobileOpen(false)
|
||||||
}, [pathname])
|
}, [pathname])
|
||||||
|
|
||||||
// Persist sidebar state
|
// ── Restore sidebar collapsed state from localStorage ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem("sidebar-collapsed")
|
const saved = localStorage.getItem("sidebar-collapsed")
|
||||||
if (saved) setSidebarCollapsed(saved === "true")
|
if (saved) setSidebarCollapsed(saved === "true")
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Toggle sidebar and persist preference ──
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
const next = !sidebarCollapsed
|
const next = !sidebarCollapsed
|
||||||
setSidebarCollapsed(next)
|
setSidebarCollapsed(next)
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
// ── Layout: CRM Icon SVG Component ──
|
||||||
|
// Decorative SVG icon representing the CRM, with dark/light mode fill adaptation.
|
||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CrmIcon — a stylized SVG icon for the CRM brand.
|
||||||
|
* Uses CSS-driven fills that switch between light and dark mode.
|
||||||
|
*/
|
||||||
export function CrmIcon() {
|
export function CrmIcon() {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user