Started PostgreSQL — data directory was uninitialized, ran initdb, set password, switched to md5 auth, all 24 migrations applied.

Hardened db.ts — added statement_timeout: 30000, idle_in_transaction_session_timeout: 10000, created transaction() helper (BEGIN/COMMIT/ROLLBACK).
Multi-step API routes wrapped in transactions — Events POST, Conversations POST use atomic blocks.
Fixed leads pagination — status filter pushed into SQL WHERE (no client-side filtering after LIMIT/OFFSET), added SELECT COUNT(*) for total.
Rewrote dashboard — SQL aggregations (COUNT + GROUP BY + date_trunc) replace loading all leads into memory.
Optimized conversations GET — merged duplicate correlated subqueries into single LEFT JOIN LATERAL.
Created migration 021_performance_indexes.sql — 8 missing indexes + set_session_user_context() function caching current_user_hierarchy_level() as session variable (avoids per-query RLS join).
Fixed build error — duplicate const other in conversations route. Also fixed a TypeScript never error in dashboard breakdown map.
Verified — npx tsc --noEmit clean, npm test (13 pass, 7 integration skip gracefully), npx next build succeeds.
This commit is contained in:
2026-07-06 13:36:48 +02:00
parent 80dee367e8
commit bc83af8e00
28 changed files with 2274 additions and 352 deletions
@@ -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;
+3
View File
@@ -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;
+16
View File
@@ -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
+1499 -11
View File
File diff suppressed because it is too large Load Diff
+13 -10
View File
@@ -6,22 +6,24 @@
"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",
@@ -81,6 +83,7 @@
"maildev": "^2.2.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5"
"typescript": "^5",
"vitest": "^1.6.1"
}
}
+3 -63
View File
@@ -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! ===")
+5 -2
View File
@@ -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 })
+2 -1
View File
@@ -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 ? (
-11
View File
@@ -1,11 +0,0 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
export async function GET() {
const cookieStore = await cookies()
const token = cookieStore.get("session")?.value
if (!token) {
return NextResponse.json({ error: "No session" }, { status: 401 })
}
return NextResponse.json({ token })
}
+1 -1
View File
@@ -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) {
+16 -7
View File
@@ -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(
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(
await client.query(
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
[conversationId, user.id, userId],
)
const otherUser = await query(
const otherResult = await client.query(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
FROM users WHERE id = $1`,
[userId],
)
const other = otherUser.rows[0]
return { conversationId, other: otherResult.rows[0] }
})
const { conversationId, other } = result
return NextResponse.json({
conversation: {
+111 -78
View File
@@ -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" },
+63 -77
View File
@@ -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(
// 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,
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,
clientName || null, clientEmail || null, clientPhone || null,
],
user.id,
)
const r = ins.rows[0]
const r = result.rows[0]
// Auto-update lead to "pending" when a website creation event is created
// 2. Auto-update lead stage if website creation event
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) {
await query(
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
await client.query(
"UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL",
[stageResult.rows[0].id, leadId],
)
}
}
// 3. Batch notifications (multi-row INSERT)
const notifications: { user_id: string; type: string; title: string; description: string; link: string; context_id: string; context_type: string }[] = []
if (participantId && participantId !== user.id) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
participantId,
"event_scheduled",
`Meeting scheduled: ${title}`,
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
`/calendar`,
r.id,
"scheduled_event",
],
)
notifications.push({
user_id: participantId, type: "event_scheduled",
title: `Meeting scheduled: ${title}`,
description: `${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
})
}
// 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",
],
notifications.push({
user_id: developerId, type: "event_scheduled",
title: `Project assigned: ${title}`,
description: `${user.firstName} ${user.lastName} assigned a project to you`,
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
})
}
if (notifications.length > 0) {
const placeholders = notifications.map((_, i) =>
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7})`
).join(", ")
const flatParams = notifications.flatMap(n => [n.user_id, n.type, n.title, n.description, n.link, n.context_id, n.context_type])
await client.query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) VALUES ${placeholders}`,
flatParams,
)
}
const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId],
) : null
const participant = participantResult?.rows[0] || null
// 4. Fetch participant + developer in one query
const idsToFetch = [participantId, developerId].filter(Boolean) as string[]
let usersMap: Record<string, { id: string; first_name: string; last_name: string; email: string }> = {}
if (idsToFetch.length > 0) {
const userPlaceholders = idsToFetch.map((_, i) => `$${i + 1}`).join(", ")
const userRows = await client.query(
`SELECT id, first_name, last_name, email FROM users WHERE id IN (${userPlaceholders})`,
idsToFetch,
)
for (const row of userRows.rows) {
usersMap[row.id] = row
}
}
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
return { r, usersMap }
}, user.id)
const { r, usersMap } = result
const participant = participantId ? usersMap[participantId] || null : null
const dev = developerId ? usersMap[developerId] || null : null
// Email sent asynchronously outside transaction (side effect)
sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
@@ -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) {
+27 -3
View File
@@ -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
View File
@@ -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 })
+10
View File
@@ -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 })
+3 -2
View File
@@ -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>
)
})}
+2 -33
View File
@@ -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
View File
@@ -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
+34
View File
@@ -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())
}
+6
View File
@@ -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
View File
@@ -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 = {
+44
View File
@@ -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)
})
})
+59
View File
@@ -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)
})
})
+59
View File
@@ -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)
})
})
+57
View File
@@ -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()
})
})
+42
View File
@@ -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"')
})
})
+14
View File
@@ -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"),
},
},
})