Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
This commit is contained in:
@@ -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) ==='
|
||||
\i 020_fixes.sql
|
||||
|
||||
\echo '=== Running 021_performance_indexes.sql (Performance Indexes + RLS Cache) ==='
|
||||
\i 021_performance_indexes.sql
|
||||
|
||||
\echo '=== Migration Complete ==='
|
||||
COMMIT;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals.js";
|
||||
import nextTs from "eslint-config-next/typescript.js";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
|
||||
@@ -12,6 +12,22 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
...(process.env.NODE_ENV === "production"
|
||||
? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
||||
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:signaling": "node signaling-server.mjs",
|
||||
"dev:open": "node scripts/open-browser.mjs",
|
||||
"dev:repair": "node scripts/code-repair-agent.mjs --watch",
|
||||
"dev:start": "concurrently -n REPAIR,AI,BROWSE,SIGNAL,NEXT,OPEN -c red,cyan,magenta,yellow,green,white \"npm run dev:repair\" \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
||||
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
||||
"dev:next": "next dev -p 3006",
|
||||
"dev:precheck": "node scripts/precheck.mjs && node scripts/run-migrations.mjs",
|
||||
"dev:precheck": "node scripts/precheck.mjs",
|
||||
"dev:ollama": "node scripts/ensure-ollama.mjs",
|
||||
"dev:rust": "node ai-server/index.mjs",
|
||||
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
|
||||
"setup": "node scripts/setup.mjs",
|
||||
"setup:self-heal": "node scripts/setup.mjs --self-heal",
|
||||
"repair": "node scripts/code-repair-agent.mjs",
|
||||
"repair:watch": "node scripts/code-repair-agent.mjs --watch",
|
||||
"repair:ci": "node scripts/code-repair-agent.mjs --ci",
|
||||
"build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci",
|
||||
"migrate": "node scripts/run-migrations.mjs",
|
||||
"db:migrate": "npm run migrate",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3006",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"ci": "npm run lint && npx tsc --noEmit && npm test",
|
||||
"repair": "echo 'Auto-repair disabled for security. Fix errors manually.'",
|
||||
"repair:watch": "echo 'Auto-repair disabled for security. Fix errors manually.'",
|
||||
"repair:ci": "echo 'Auto-repair disabled for security. Fix errors manually.'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
@@ -48,7 +50,6 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"devenv": "^1.0.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"framer-motion": "^11.15.0",
|
||||
"jose": "^6.2.3",
|
||||
@@ -69,17 +70,20 @@
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@next/swc-win32-x64-msvc": "^15.0.4",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react": "18.3.31",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"concurrently": "^10.0.3",
|
||||
"devenv": "1.0.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.0.4",
|
||||
"maildev": "^2.2.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
// Verifies all packages listed in package.json are installed.
|
||||
// Auto-runs npm install if any are missing — zero manual setup steps.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { readFileSync, existsSync } from "node:fs"
|
||||
import { resolve, dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { createRequire } from "node:module"
|
||||
import { execSync } from "node:child_process"
|
||||
import { platform } from "node:os"
|
||||
|
||||
@@ -15,10 +14,10 @@ const root = resolve(__dirname, "..")
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"))
|
||||
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const missing = Object.keys(allDeps).filter(name => {
|
||||
try { require.resolve(name, { paths: [root] }); return false } catch { return true }
|
||||
const pkgDir = resolve(root, "node_modules", name)
|
||||
return !existsSync(resolve(pkgDir, "package.json"))
|
||||
})
|
||||
|
||||
if (missing.length > 0) {
|
||||
|
||||
+3
-63
@@ -177,8 +177,6 @@ function parseMissingModules(buildOutput) {
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const doSelfHeal = args.includes("--self-heal") || args.includes("-s")
|
||||
const PY = detectPython()
|
||||
const PIP = detectPip(PY)
|
||||
|
||||
@@ -202,69 +200,11 @@ if (!existsSync(resolve(ROOT, ".env.local"))) {
|
||||
console.log("\n── .env.local already exists, skipping ──")
|
||||
}
|
||||
|
||||
// 5. Database migrations (auto-applies on fresh install)
|
||||
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
|
||||
// 5. Final summary
|
||||
console.log("\n── Final Steps ──")
|
||||
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
|
||||
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
|
||||
console.log(" 3. Edit .env.local with your settings")
|
||||
console.log(" 4. Run: npm run dev")
|
||||
console.log(" 4. Run: npm run db:migrate (apply database migrations)")
|
||||
console.log(" 5. Run: npm run dev")
|
||||
console.log("\n=== Setup complete! ===")
|
||||
|
||||
@@ -4,8 +4,11 @@ import { SignJWT, jwtVerify } from "jose"
|
||||
import pg from "pg"
|
||||
|
||||
const PORT = process.env.SIGNALING_PORT || 3007
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026")
|
||||
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm"
|
||||
const rawSecret = process.env.JWT_SECRET
|
||||
if (!rawSecret) throw new Error("JWT_SECRET environment variable is required")
|
||||
const JWT_SECRET = new TextEncoder().encode(rawSecret)
|
||||
const DATABASE_URL = process.env.DATABASE_URL
|
||||
if (!DATABASE_URL) throw new Error("DATABASE_URL environment variable is required")
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL })
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
||||
} from "lucide-react"
|
||||
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
||||
import { sanitizeSvg } from "@/lib/sanitize"
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
DialogFooter, DialogClose,
|
||||
@@ -1041,7 +1042,7 @@ const formatPreviewContent = (content: string) => {
|
||||
{stickerData.emoji ? (
|
||||
<span className="text-7xl block text-center">{stickerData.emoji}</span>
|
||||
) : (
|
||||
<div className="w-full" dangerouslySetInnerHTML={{ __html: stickerData.url.replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
|
||||
<div className="w-full" dangerouslySetInnerHTML={{ __html: sanitizeSvg(stickerData.url).replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
|
||||
)}
|
||||
</div>
|
||||
) : voiceUrl ? (
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export async function POST() {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`,
|
||||
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${process.env.NODE_ENV === "production" ? "; Secure" : ""}`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { query, transaction } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
export async function GET() {
|
||||
@@ -18,13 +18,18 @@ export async function GET() {
|
||||
u.email AS other_user_email,
|
||||
u.phone AS other_user_phone,
|
||||
u.avatar_url AS other_user_avatar_url,
|
||||
(SELECT content FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT created_at FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
||||
lm.last_message_data->>'content' AS last_message,
|
||||
(lm.last_message_data->>'created_at')::timestamptz AS last_message_time,
|
||||
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
|
||||
FROM conversations c
|
||||
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
|
||||
JOIN conversation_participants cp ON cp.conversation_id = c.id
|
||||
JOIN users u ON u.id = cp.user_id AND u.id != $1
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT jsonb_build_object('content', content, 'created_at', created_at) AS last_message_data
|
||||
FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) lm ON true
|
||||
WHERE c.id IN (
|
||||
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
||||
)
|
||||
@@ -80,23 +85,27 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ conversationId: existing.rows[0].id })
|
||||
}
|
||||
|
||||
const convResult = await query(
|
||||
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
|
||||
)
|
||||
const conversationId = convResult.rows[0].id
|
||||
const result = await transaction(async (client) => {
|
||||
const convResult = await client.query(
|
||||
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
|
||||
)
|
||||
const conversationId = convResult.rows[0].id
|
||||
|
||||
await query(
|
||||
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
||||
[conversationId, user.id, userId],
|
||||
)
|
||||
await client.query(
|
||||
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
||||
[conversationId, user.id, userId],
|
||||
)
|
||||
|
||||
const otherUser = await query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
const otherResult = await client.query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
const other = otherUser.rows[0]
|
||||
return { conversationId, other: otherResult.rows[0] }
|
||||
})
|
||||
|
||||
const { conversationId, other } = result
|
||||
|
||||
return NextResponse.json({
|
||||
conversation: {
|
||||
|
||||
+111
-78
@@ -48,74 +48,22 @@ function stageToStatus(name: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmin?: boolean) {
|
||||
const result = await query(
|
||||
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
||||
l.notes, l.assigned_to, l.score,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND l.created_at >= $1 AND l.created_at <= $2
|
||||
${isAdmin ? "" : "AND l.assigned_to = $3"}
|
||||
ORDER BY l.created_at DESC`,
|
||||
isAdmin
|
||||
? [start.toISOString(), end.toISOString()]
|
||||
: [start.toISOString(), end.toISOString(), userId]
|
||||
)
|
||||
return result.rows.map((r: any) => ({
|
||||
...r,
|
||||
status: stageToStatus(r.stage_name),
|
||||
}))
|
||||
}
|
||||
|
||||
function countStatuses(leads: any[]) {
|
||||
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||
leads.forEach((l: any) => {
|
||||
const s = l.status as keyof typeof counts
|
||||
if (s in counts) counts[s]++
|
||||
})
|
||||
return counts
|
||||
}
|
||||
|
||||
function buildMonthlyBreakdown(leads: any[], period: string, rangeOverride?: { start: Date; end: Date }) {
|
||||
const { start, end } = rangeOverride || getPeriodDateRange(period)
|
||||
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
|
||||
const current = new Date(start)
|
||||
const isMonthly = period === "6months" || period === "12months"
|
||||
|
||||
while (current <= end) {
|
||||
const label = isMonthly
|
||||
? current.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
||||
: current.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||
|
||||
const ps = new Date(current)
|
||||
const pe = isMonthly
|
||||
? new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59)
|
||||
: (() => { const d = new Date(current); d.setHours(23, 59, 59, 999); return d })()
|
||||
|
||||
const inPeriod = leads.filter((l: any) => {
|
||||
const d = new Date(l.created_at)
|
||||
return d >= ps && d <= pe
|
||||
})
|
||||
|
||||
const counts = countStatuses(inPeriod)
|
||||
result.push({ label, total: inPeriod.length, ...counts })
|
||||
|
||||
if (isMonthly) current.setMonth(current.getMonth() + 1)
|
||||
else current.setDate(current.getDate() + 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
|
||||
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
|
||||
const pct = Math.round(((current - previous) / previous) * 100)
|
||||
return { pct: Math.abs(pct), up: pct >= 0 }
|
||||
}
|
||||
|
||||
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`
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
@@ -138,19 +86,107 @@ export async function GET(request: NextRequest) {
|
||||
prevRange = getPreviousPeriodRange(period, start)
|
||||
}
|
||||
|
||||
const [currentLeads, prevLeads] = await Promise.all([
|
||||
fetchLeadsInRange(start, end, user.id, isAdmin),
|
||||
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
|
||||
])
|
||||
const ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3"
|
||||
|
||||
const currentCounts = countStatuses(currentLeads)
|
||||
const prevCounts = countStatuses(prevLeads)
|
||||
// Status counts for current period (SQL aggregation)
|
||||
const countSql = `
|
||||
SELECT ${stageStatusSql} AS status, COUNT(*) AS count
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND l.created_at >= $1 AND l.created_at <= $2
|
||||
${ownerFilter}
|
||||
GROUP BY ${stageStatusSql}`
|
||||
|
||||
const totalLeads = currentLeads.length
|
||||
const currentCountRows = await query(
|
||||
countSql,
|
||||
isAdmin
|
||||
? [start.toISOString(), end.toISOString()]
|
||||
: [start.toISOString(), end.toISOString(), user.id],
|
||||
)
|
||||
|
||||
const prevCountRows = await query(
|
||||
countSql,
|
||||
isAdmin
|
||||
? [prevRange.start.toISOString(), prevRange.end.toISOString()]
|
||||
: [prevRange.start.toISOString(), prevRange.end.toISOString(), user.id],
|
||||
)
|
||||
|
||||
function rowsToCounts(rows: any[]) {
|
||||
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||
for (const r of rows) {
|
||||
const s = r.status as keyof typeof counts
|
||||
if (s in counts) counts[s] = parseInt(r.count, 10)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
const currentCounts = rowsToCounts(currentCountRows.rows)
|
||||
const prevCounts = rowsToCounts(prevCountRows.rows)
|
||||
|
||||
const totalLeads = currentCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0)
|
||||
const prevTotal = prevCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0)
|
||||
const closedLeads = currentCounts.closed
|
||||
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
||||
|
||||
const mappedLeads = currentLeads.map((r: any) => ({
|
||||
// Monthly/weekly breakdown via date_trunc
|
||||
const isMonthly = period === "6months" || period === "12months"
|
||||
const truncUnit = isMonthly ? "month" : "day"
|
||||
const breakdownSql = `
|
||||
SELECT DATE_TRUNC($1, l.created_at) AS period_start,
|
||||
${stageStatusSql} AS status,
|
||||
COUNT(*) AS count
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND l.created_at >= $2 AND l.created_at <= $3
|
||||
${isAdmin ? "" : "AND l.assigned_to = $4"}
|
||||
GROUP BY DATE_TRUNC($1, l.created_at), ${stageStatusSql}
|
||||
ORDER BY period_start ASC`
|
||||
const breakdownParams = isAdmin
|
||||
? [truncUnit, start.toISOString(), end.toISOString()]
|
||||
: [truncUnit, start.toISOString(), end.toISOString(), user.id]
|
||||
const breakdownResult = await query(breakdownSql, breakdownParams)
|
||||
|
||||
// Build monthly breakdown from aggregated data
|
||||
const breakdownMap: Record<string, { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }> = {}
|
||||
for (const r of breakdownResult.rows) {
|
||||
const d = new Date(r.period_start)
|
||||
const label = isMonthly
|
||||
? d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
||||
: d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||
if (!breakdownMap[label]) {
|
||||
breakdownMap[label] = { label, total: 0, open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||
}
|
||||
const count = parseInt(r.count, 10)
|
||||
breakdownMap[label].total += count
|
||||
const statusKey = r.status as 'open' | 'contacted' | 'pending' | 'closed' | 'ignored'
|
||||
breakdownMap[label][statusKey] = count
|
||||
}
|
||||
const monthlyBreakdown = Object.values(breakdownMap)
|
||||
|
||||
// Recent 10 leads
|
||||
const recentSql = `
|
||||
SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
||||
l.notes, l.assigned_to, l.score,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND l.created_at >= $1 AND l.created_at <= $2
|
||||
${ownerFilter}
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 10`
|
||||
const recentResult = await query(
|
||||
recentSql,
|
||||
isAdmin
|
||||
? [start.toISOString(), end.toISOString()]
|
||||
: [start.toISOString(), end.toISOString(), user.id],
|
||||
)
|
||||
|
||||
const recentLeads = recentResult.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
companyName: r.company_name || "",
|
||||
contactName: r.contact_name,
|
||||
@@ -158,7 +194,7 @@ export async function GET(request: NextRequest) {
|
||||
phone: r.phone || "",
|
||||
source: "",
|
||||
description: r.notes || "",
|
||||
status: r.status,
|
||||
status: stageToStatus(r.stage_name),
|
||||
assignedUserId: r.assigned_to,
|
||||
assignedUser: r.assigned_to ? {
|
||||
id: r.user_id,
|
||||
@@ -170,17 +206,14 @@ export async function GET(request: NextRequest) {
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
|
||||
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period, yearParam ? { start, end } : undefined)
|
||||
|
||||
const trends = {
|
||||
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
||||
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
|
||||
totalLeads: computeTrend(totalLeads, prevTotal),
|
||||
openLeads: computeTrend(currentCounts.open, prevCounts.open),
|
||||
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
|
||||
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
|
||||
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
|
||||
conversionRate: computeTrend(conversionRate,
|
||||
prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0),
|
||||
prevTotal > 0 ? Math.round((prevCounts.closed / prevTotal) * 100) : 0),
|
||||
}
|
||||
|
||||
const stats = {
|
||||
@@ -192,9 +225,9 @@ export async function GET(request: NextRequest) {
|
||||
ignoredLeads: currentCounts.ignored,
|
||||
conversionRate,
|
||||
monthlyBreakdown,
|
||||
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
||||
leadsPerMonth: monthlyBreakdown.map((m) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
||||
trends,
|
||||
recentLeads: mappedLeads.slice(0, 10),
|
||||
recentLeads,
|
||||
statusDistribution: [
|
||||
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
||||
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
||||
|
||||
+79
-93
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { query, transaction } from "@/lib/db"
|
||||
import { sendEventConfirmation } from "@/lib/email"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -119,87 +119,85 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
|
||||
[
|
||||
user.id,
|
||||
participantId || null,
|
||||
developerId || null,
|
||||
leadId || null,
|
||||
conversationId || null,
|
||||
title,
|
||||
description || null,
|
||||
eventType || "website_creation",
|
||||
startTime || null,
|
||||
eventType === "website_creation" ? null : (endTime || null),
|
||||
eventType === "website_creation" ? null : (durationMinutes || null),
|
||||
clientName || null,
|
||||
clientEmail || null,
|
||||
clientPhone || null,
|
||||
],
|
||||
user.id,
|
||||
)
|
||||
// Single transaction for all DB operations
|
||||
const result = await transaction(async (client) => {
|
||||
// 1. Insert event
|
||||
const ins = await client.query(
|
||||
`INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
|
||||
[
|
||||
user.id, participantId || null, developerId || null, leadId || null, conversationId || null,
|
||||
title, description || null, eventType || "website_creation", startTime || null,
|
||||
eventType === "website_creation" ? null : (endTime || null),
|
||||
eventType === "website_creation" ? null : (durationMinutes || null),
|
||||
clientName || null, clientEmail || null, clientPhone || null,
|
||||
],
|
||||
)
|
||||
const r = ins.rows[0]
|
||||
|
||||
const r = result.rows[0]
|
||||
// 2. Auto-update lead stage if website creation event
|
||||
if (r.event_type === "website_creation" && leadId) {
|
||||
const stageResult = await client.query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
|
||||
if (stageResult.rows.length > 0) {
|
||||
await client.query(
|
||||
"UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL",
|
||||
[stageResult.rows[0].id, leadId],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-update lead to "pending" when a website creation event is created
|
||||
if (r.event_type === "website_creation" && leadId) {
|
||||
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
|
||||
if (stageResult.rows.length > 0) {
|
||||
await query(
|
||||
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
|
||||
[stageResult.rows[0].id, leadId],
|
||||
// 3. Batch notifications (multi-row INSERT)
|
||||
const notifications: { user_id: string; type: string; title: string; description: string; link: string; context_id: string; context_type: string }[] = []
|
||||
if (participantId && participantId !== user.id) {
|
||||
notifications.push({
|
||||
user_id: participantId, type: "event_scheduled",
|
||||
title: `Meeting scheduled: ${title}`,
|
||||
description: `${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
|
||||
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
|
||||
})
|
||||
}
|
||||
if (developerId && developerId !== user.id) {
|
||||
notifications.push({
|
||||
user_id: developerId, type: "event_scheduled",
|
||||
title: `Project assigned: ${title}`,
|
||||
description: `${user.firstName} ${user.lastName} assigned a project to you`,
|
||||
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
|
||||
})
|
||||
}
|
||||
if (notifications.length > 0) {
|
||||
const placeholders = notifications.map((_, i) =>
|
||||
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7})`
|
||||
).join(", ")
|
||||
const flatParams = notifications.flatMap(n => [n.user_id, n.type, n.title, n.description, n.link, n.context_id, n.context_type])
|
||||
await client.query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) VALUES ${placeholders}`,
|
||||
flatParams,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (participantId && participantId !== user.id) {
|
||||
await query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
participantId,
|
||||
"event_scheduled",
|
||||
`Meeting scheduled: ${title}`,
|
||||
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
|
||||
`/calendar`,
|
||||
r.id,
|
||||
"scheduled_event",
|
||||
],
|
||||
)
|
||||
}
|
||||
// 4. Fetch participant + developer in one query
|
||||
const idsToFetch = [participantId, developerId].filter(Boolean) as string[]
|
||||
let usersMap: Record<string, { id: string; first_name: string; last_name: string; email: string }> = {}
|
||||
if (idsToFetch.length > 0) {
|
||||
const userPlaceholders = idsToFetch.map((_, i) => `$${i + 1}`).join(", ")
|
||||
const userRows = await client.query(
|
||||
`SELECT id, first_name, last_name, email FROM users WHERE id IN (${userPlaceholders})`,
|
||||
idsToFetch,
|
||||
)
|
||||
for (const row of userRows.rows) {
|
||||
usersMap[row.id] = row
|
||||
}
|
||||
}
|
||||
|
||||
// Notify developer
|
||||
if (developerId && developerId !== user.id) {
|
||||
await query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
developerId,
|
||||
"event_scheduled",
|
||||
`Project assigned: ${title}`,
|
||||
`${user.firstName} ${user.lastName} assigned a project to you`,
|
||||
`/calendar`,
|
||||
r.id,
|
||||
"scheduled_event",
|
||||
],
|
||||
)
|
||||
}
|
||||
return { r, usersMap }
|
||||
}, user.id)
|
||||
|
||||
const participantResult = participantId ? await query(
|
||||
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
|
||||
[participantId],
|
||||
) : null
|
||||
const participant = participantResult?.rows[0] || null
|
||||
|
||||
const developerResult = developerId ? await query(
|
||||
`SELECT id, first_name, last_name, email FROM users WHERE id = $1`,
|
||||
[developerId],
|
||||
) : null
|
||||
const dev = developerResult?.rows[0] || null
|
||||
const { r, usersMap } = result
|
||||
const participant = participantId ? usersMap[participantId] || null : null
|
||||
const dev = developerId ? usersMap[developerId] || null : null
|
||||
|
||||
// Email sent asynchronously outside transaction (side effect)
|
||||
sendEventConfirmation({
|
||||
creatorName: `${user.firstName} ${user.lastName}`,
|
||||
creatorEmail: user.email,
|
||||
@@ -215,28 +213,16 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
event: {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
participantId: r.participant_id,
|
||||
developerId: r.developer_id,
|
||||
leadId: r.lead_id,
|
||||
conversationId: r.conversation_id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
participantNotes: r.participant_notes,
|
||||
eventType: r.event_type,
|
||||
startTime: r.start_time,
|
||||
endTime: r.end_time,
|
||||
durationMinutes: r.duration_minutes,
|
||||
status: r.status,
|
||||
id: r.id, userId: r.user_id, participantId: r.participant_id, developerId: r.developer_id,
|
||||
leadId: r.lead_id, conversationId: r.conversation_id,
|
||||
title: r.title, description: r.description, participantNotes: r.participant_notes,
|
||||
eventType: r.event_type, startTime: r.start_time, endTime: r.end_time,
|
||||
durationMinutes: r.duration_minutes, status: r.status, createdAt: r.created_at,
|
||||
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
|
||||
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
|
||||
participant: participant ? { id: participantId, name: `${participant.first_name} ${participant.last_name}`, email: participant.email } : null,
|
||||
developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null,
|
||||
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
|
||||
clientName: r.client_name || null,
|
||||
clientEmail: r.client_email || null,
|
||||
clientPhone: r.client_phone || null,
|
||||
createdAt: r.created_at,
|
||||
clientName: r.client_name || null, clientEmail: r.client_email || null, clientPhone: r.client_phone || null,
|
||||
},
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser, setSessionContext } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import crypto from "crypto"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { phone, token: clientToken } = await request.json()
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
if (sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Only SUPER_ADMIN can generate invites." }, { status: 403 })
|
||||
}
|
||||
|
||||
const { phone } = await request.json()
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = clientToken || crypto.randomBytes(24).toString("hex")
|
||||
const ipAddress =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"127.0.0.1"
|
||||
|
||||
await setSessionContext(sessionUser.id, ipAddress)
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString("hex")
|
||||
await query(
|
||||
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
||||
[token, phone],
|
||||
@@ -19,7 +42,8 @@ export async function POST(request: NextRequest) {
|
||||
const inviteUrl = `${origin}/join/${token}`
|
||||
|
||||
return NextResponse.json({ token, inviteUrl })
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("Invite generation error:", error)
|
||||
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
+46
-23
@@ -43,47 +43,74 @@ export async function GET(request: NextRequest) {
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE l.deleted_at IS NULL`
|
||||
|
||||
const params: any[] = []
|
||||
let paramIdx = 1
|
||||
|
||||
const conditions: string[] = ["l.deleted_at IS NULL"]
|
||||
|
||||
if (period !== "all") {
|
||||
const range = getPeriodDateRange(period)
|
||||
if (range) {
|
||||
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
|
||||
conditions.push(`l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`)
|
||||
params.push(range.start.toISOString(), range.end.toISOString())
|
||||
paramIdx += 2
|
||||
}
|
||||
}
|
||||
|
||||
if (search) {
|
||||
sql += ` AND (l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`
|
||||
conditions.push(`(l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`)
|
||||
params.push(`%${search}%`)
|
||||
paramIdx++
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
sql += ` AND l.assigned_to = $${paramIdx}`
|
||||
conditions.push(`l.assigned_to = $${paramIdx}`)
|
||||
params.push(user.id)
|
||||
paramIdx++
|
||||
}
|
||||
|
||||
sql += ` ORDER BY l.created_at DESC`
|
||||
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
|
||||
params.push(limit, offset)
|
||||
paramIdx += 2
|
||||
// Push status filter into SQL (avoid client-side filtering after pagination)
|
||||
const statusStageMap: Record<string, string[]> = {
|
||||
open: ["New"],
|
||||
contacted: ["Contacted"],
|
||||
pending: ["Qualified", "Interested", "Demo Scheduled", "Negotiation"],
|
||||
closed: ["Closed Won"],
|
||||
ignored: ["Closed Lost"],
|
||||
}
|
||||
if (status !== "all") {
|
||||
const stageNames = statusStageMap[status]
|
||||
if (stageNames) {
|
||||
const stagePlaceholders = stageNames.map((_, i) => `$${paramIdx + i}`)
|
||||
conditions.push(`ls.name IN (${stagePlaceholders.join(", ")})`)
|
||||
params.push(...stageNames)
|
||||
paramIdx += stageNames.length
|
||||
}
|
||||
}
|
||||
|
||||
const result = await query(sql, params)
|
||||
const whereClause = conditions.join(" AND ")
|
||||
|
||||
let leads = result.rows.map((r: any) => {
|
||||
// Total count (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)
|
||||
|
||||
// Data query with pagination
|
||||
const dataSql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE ${whereClause}
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
|
||||
const dataParams = [...params, limit, offset]
|
||||
const result = await query(dataSql, dataParams)
|
||||
|
||||
const leads = result.rows.map((r: any) => {
|
||||
const s = stageToStatus(r.stage_name)
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -106,11 +133,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
})
|
||||
|
||||
if (status !== "all") {
|
||||
leads = leads.filter((l: any) => l.status === status)
|
||||
}
|
||||
|
||||
return NextResponse.json(leads)
|
||||
return NextResponse.json({ leads, total })
|
||||
} catch (error) {
|
||||
console.error("Leads API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
if (sessionUser.role !== "super_admin" && sessionUser.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const phone = request.nextUrl.searchParams.get("phone")
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
||||
|
||||
@@ -59,7 +59,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
||||
key={i}
|
||||
type="button"
|
||||
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="text-xs text-muted-foreground/60 mt-0.5">{job.industry} — {job.description}</div>
|
||||
@@ -76,7 +76,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
|
||||
type="button"
|
||||
onClick={() => onSearch(selected)}
|
||||
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 ? "Searching..." : "Search Facebook"}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Search, Image, Sticker, X, Loader2, TrendingUp } from "lucide-react"
|
||||
import data from "@emoji-mart/data"
|
||||
import Picker from "@emoji-mart/react"
|
||||
import { getStickerPacks, type StickerPack } from "@/data/stickers"
|
||||
import { sanitizeSvg } from "@/lib/sanitize"
|
||||
|
||||
type Tab = "emoji" | "gif" | "sticker"
|
||||
|
||||
@@ -287,7 +288,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
||||
className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square"
|
||||
>
|
||||
{sticker.svg ? (
|
||||
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sticker.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
|
||||
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sanitizeSvg(sticker.svg).replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
|
||||
) : (
|
||||
<span className="text-5xl">{sticker.emoji}</span>
|
||||
)}
|
||||
@@ -303,7 +304,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
||||
if (!s) return null
|
||||
return (
|
||||
<button key={s.id} type="button" onClick={() => handleStickerSelect(s)} className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square opacity-70 hover:opacity-100">
|
||||
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: s.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
|
||||
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sanitizeSvg(s.svg).replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -61,7 +61,8 @@ export function ThemeSettings() {
|
||||
<Label
|
||||
htmlFor={`mode-${value}`}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all",
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 cursor-pointer transition-all",
|
||||
value === "dark" ? "hover:bg-muted" : "hover:bg-accent",
|
||||
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
|
||||
+2
-33
@@ -1,21 +1,7 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { query } from "@/lib/db";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
function randomHex(length: number): string {
|
||||
const chars = "abcdef0123456789";
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const g = globalThis as typeof globalThis & { __JWT_RAW_SECRET?: string };
|
||||
const RAW_SECRET = process.env.JWT_SECRET || (g.__JWT_RAW_SECRET ??= randomHex(32));
|
||||
if (!RAW_SECRET) throw new Error("JWT_SECRET environment variable is required");
|
||||
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
|
||||
import { signToken, verifyToken } from "@/lib/jwt";
|
||||
|
||||
export const SESSION_COOKIE = "session";
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
@@ -43,23 +29,6 @@ export async function comparePassword(
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
export async function signToken(payload: { userId: string; role: string }) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("24h")
|
||||
.setIssuedAt()
|
||||
.sign(JWT_SECRET);
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
return payload as { userId: string; role: string };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const result = await query(
|
||||
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||
@@ -221,7 +190,7 @@ export async function decryptPassword(encrypted: string): Promise<string | null>
|
||||
}
|
||||
|
||||
export async function setSessionContext(userId: string, ip?: string) {
|
||||
await query("SELECT set_config('app.current_user_id', $1, true)", [userId]);
|
||||
await query("SELECT set_session_user_context($1)", [userId]);
|
||||
if (ip) {
|
||||
await query("SELECT set_config('app.current_ip', $1, true)", [ip]);
|
||||
}
|
||||
|
||||
+24
-1
@@ -1,4 +1,4 @@
|
||||
import { Pool } from "pg"
|
||||
import { Pool, PoolClient } from "pg"
|
||||
|
||||
const dbUrl = process.env.DATABASE_URL
|
||||
if (!dbUrl) {
|
||||
@@ -9,6 +9,8 @@ const pool = new Pool({
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
statement_timeout: 30000,
|
||||
idle_in_transaction_session_timeout: 10000,
|
||||
ssl: dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1") ? false : { rejectUnauthorized: false },
|
||||
})
|
||||
|
||||
@@ -29,4 +31,25 @@ export async function query(text: string, params?: unknown[], userId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function transaction<T>(
|
||||
callback: (client: PoolClient) => Promise<T>,
|
||||
userId?: string,
|
||||
): Promise<T> {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("BEGIN")
|
||||
if (userId) {
|
||||
await client.query("SELECT set_config('app.current_user_id', $1, true)", [userId])
|
||||
}
|
||||
const result = await callback(client)
|
||||
await client.query("COMMIT")
|
||||
return result
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK").catch(() => {})
|
||||
throw e
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
export default pool
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { jwtVerify, SignJWT } from "jose"
|
||||
|
||||
let encoded: Uint8Array
|
||||
|
||||
export function getJWTSecret(): Uint8Array {
|
||||
if (!encoded) {
|
||||
const raw = process.env.JWT_SECRET
|
||||
if (!raw) {
|
||||
throw new Error(
|
||||
"JWT_SECRET environment variable is required. " +
|
||||
"Generate one: openssl rand -hex 32",
|
||||
)
|
||||
}
|
||||
encoded = new TextEncoder().encode(raw)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJWTSecret())
|
||||
return payload as { userId: string; role: string }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function signToken(payload: { userId: string; role: string }) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("24h")
|
||||
.setIssuedAt()
|
||||
.sign(getJWTSecret())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function sanitizeSvg(svg: string): string {
|
||||
return svg
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
|
||||
.replace(/\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
||||
.replace(/javascript\s*:/gi, "")
|
||||
}
|
||||
+20
-3
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
import { verifyToken } from "@/lib/jwt"
|
||||
|
||||
const publicRoutes = [
|
||||
"/login",
|
||||
@@ -12,6 +13,8 @@ const publicRoutes = [
|
||||
"/fonts",
|
||||
]
|
||||
|
||||
const SESSION_COOKIE = "session"
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
@@ -23,9 +26,9 @@ export async function middleware(request: NextRequest) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const sessionCookie = request.cookies.get("session")?.value
|
||||
const token = request.cookies.get(SESSION_COOKIE)?.value
|
||||
|
||||
if (!sessionCookie) {
|
||||
if (!token) {
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
@@ -34,7 +37,21 @@ export async function middleware(request: NextRequest) {
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
const payload = await verifyToken(token)
|
||||
if (!payload) {
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
const loginUrl = new URL("/login", request.url)
|
||||
loginUrl.searchParams.set("redirect", pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(request.headers)
|
||||
requestHeaders.set("x-user-id", payload.userId)
|
||||
requestHeaders.set("x-user-role", payload.role)
|
||||
|
||||
return NextResponse.next({ request: { headers: requestHeaders } })
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
const API_BASE = "http://localhost:3006"
|
||||
|
||||
async function loginAs(role: string) {
|
||||
const credentials: Record<string, { email: string; password: string }> = {
|
||||
admin: { email: "admin@coastit.co.za", password: "AdminAccess@2026" },
|
||||
superadmin: { email: "superadmin@coastit.co.za", password: "SuperAdmin@2026" },
|
||||
sales: { email: "sales@coastit.co.za", password: "SalesAccess@2026" },
|
||||
dev: { email: "dev@coastit.co.za", password: "DevTesting@2026" },
|
||||
}
|
||||
const cred = credentials[role]
|
||||
if (!cred) return null
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(cred),
|
||||
})
|
||||
if (res.status !== 200) return null
|
||||
return res.headers.get("set-cookie")?.split(";")[0]
|
||||
}
|
||||
|
||||
describe("Bug Reports", () => {
|
||||
it("allows any authenticated user to submit a bug report", async () => {
|
||||
const cookie = await loginAs("dev")
|
||||
if (!cookie) return
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/bug-reports`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify({ title: "Test bug", description: "This is a test bug report" }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it("rejects unauthenticated bug report submission", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/bug-reports`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Test", description: "Test" }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// These tests require a running PostgreSQL database with the CRM schema.
|
||||
// Run: npm run dev (starts the server on port 3006)
|
||||
// Then: npx vitest run
|
||||
|
||||
const API_BASE = "http://localhost:3006"
|
||||
|
||||
async function loginAs(role: string) {
|
||||
const credentials: Record<string, { email: string; password: string }> = {
|
||||
admin: { email: "superadmin@coastit.co.za", password: "SuperAdmin@2026" },
|
||||
sales: { email: "sales@coastit.co.za", password: "SalesAccess@2026" },
|
||||
}
|
||||
const cred = credentials[role] || credentials.admin
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(cred),
|
||||
})
|
||||
if (res.status !== 200) return null
|
||||
const setCookie = res.headers.get("set-cookie")
|
||||
return setCookie?.split(";")[0] // return the raw cookie value
|
||||
}
|
||||
|
||||
describe("Leads API", () => {
|
||||
it("creates a lead when authenticated", async () => {
|
||||
const cookie = await loginAs("sales")
|
||||
if (!cookie) return // skip if DB not available
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/leads`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify({
|
||||
company_name: "Test Company",
|
||||
contact_name: "Test Contact",
|
||||
email: "test@example.com",
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// Cleanup
|
||||
const data = await res.json()
|
||||
if (data?.id) {
|
||||
await fetch(`${API_BASE}/api/leads/${data.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: cookie },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects unauthenticated lead creation", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/leads`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ company_name: "Test", contact_name: "Test", email: "test@test.com" }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest"
|
||||
|
||||
// These tests require a running PostgreSQL database with the CRM schema.
|
||||
// Set DATABASE_URL env var or the default local connection will be used.
|
||||
//
|
||||
// Run: DATABASE_URL=postgresql://postgres:postgres@localhost:5432/crm_test npx vitest run
|
||||
|
||||
const API_BASE = "http://localhost:3006"
|
||||
|
||||
function skipIfNoDb() {
|
||||
if (!process.env.CI && !process.env.DATABASE_URL?.includes("crm_test")) {
|
||||
console.warn("Skipping DB-dependent tests. Set DATABASE_URL to a test database.")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
describe("Login", () => {
|
||||
it("returns 401 with wrong password", async () => {
|
||||
if (skipIfNoDb()) return
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "superadmin@coastit.co.za", password: "wrong" }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it("returns 400 with empty body", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it("returns 401 for non-existent user", async () => {
|
||||
if (skipIfNoDb()) return
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "nobody@example.com", password: "anything" }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Authorization", () => {
|
||||
it("rejects unauthenticated requests to protected routes", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/leads`, { headers: { "Content-Type": "application/json" } })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it("rejects unauthenticated requests to dashboard", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/dashboard`, { headers: { "Content-Type": "application/json" } })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { signToken, verifyToken } from "@/lib/jwt"
|
||||
|
||||
process.env.JWT_SECRET = "test-secret-that-is-at-least-32-chars-long-for-security"
|
||||
|
||||
describe("JWT", () => {
|
||||
it("signs and verifies a valid token", async () => {
|
||||
const token = await signToken({ userId: "user-1", role: "admin" })
|
||||
expect(token).toBeTruthy()
|
||||
expect(typeof token).toBe("string")
|
||||
|
||||
const payload = await verifyToken(token)
|
||||
expect(payload).not.toBeNull()
|
||||
expect(payload!.userId).toBe("user-1")
|
||||
expect(payload!.role).toBe("admin")
|
||||
})
|
||||
|
||||
it("rejects a tampered token", async () => {
|
||||
const token = await signToken({ userId: "user-1", role: "admin" })
|
||||
const tampered = token.slice(0, -5) + "XXXXX"
|
||||
const payload = await verifyToken(tampered)
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
|
||||
it("rejects an expired token", async () => {
|
||||
const { SignJWT } = await import("jose")
|
||||
const { getJWTSecret } = await import("@/lib/jwt")
|
||||
const expiredToken = await new SignJWT({ userId: "user-1", role: "admin" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("0s")
|
||||
.sign(getJWTSecret())
|
||||
const payload = await verifyToken(expiredToken)
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
|
||||
it("rejects a token with invalid signature", async () => {
|
||||
const { SignJWT } = await import("jose")
|
||||
const wrongSecret = new TextEncoder().encode("different-secret-key-for-signing-purposes-only")
|
||||
const token = await new SignJWT({ userId: "user-1", role: "admin" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("24h")
|
||||
.setIssuedAt()
|
||||
.sign(wrongSecret)
|
||||
const payload = await verifyToken(token)
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for empty token", async () => {
|
||||
const payload = await verifyToken("")
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for garbage token", async () => {
|
||||
const payload = await verifyToken("this.is.not.a.jwt")
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { sanitizeSvg } from "@/lib/sanitize"
|
||||
|
||||
describe("sanitizeSvg", () => {
|
||||
it("strips script tags", () => {
|
||||
const input = `<svg><script>alert('xss')</script><rect width="100" height="100"/></svg>`
|
||||
const result = sanitizeSvg(input)
|
||||
expect(result).not.toContain("script")
|
||||
expect(result).toContain("<svg")
|
||||
expect(result).toContain("<rect")
|
||||
})
|
||||
|
||||
it("strips event handler attributes", () => {
|
||||
const input = `<svg onload="alert(1)"><rect width="100" height="100"/></svg>`
|
||||
const result = sanitizeSvg(input)
|
||||
expect(result).not.toContain("onload")
|
||||
expect(result).toContain("<svg")
|
||||
expect(result).toContain("<rect")
|
||||
})
|
||||
|
||||
it("strips onclick attributes", () => {
|
||||
const input = `<svg><rect onclick="evil()" width="100" height="100"/></svg>`
|
||||
const result = sanitizeSvg(input)
|
||||
expect(result).not.toContain("onclick")
|
||||
expect(result).toContain("<rect")
|
||||
expect(result).toContain('width="100"')
|
||||
})
|
||||
|
||||
it("removes javascript: URLs", () => {
|
||||
const input = `<svg><a href="javascript:alert(1)">click</a></svg>`
|
||||
const result = sanitizeSvg(input)
|
||||
expect(result).not.toContain("javascript:")
|
||||
})
|
||||
|
||||
it("passes through clean SVGs unchanged (except whitespace)", () => {
|
||||
const input = `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>`
|
||||
const result = sanitizeSvg(input)
|
||||
expect(result).toContain("viewBox")
|
||||
expect(result).toContain("<circle")
|
||||
expect(result).toContain('cx="12"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
import path from "path"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user