Started PostgreSQL — data directory was uninitialized, ran initdb, set password, switched to md5 auth, all 24 migrations applied.
Build & Auto-Repair / build (push) Has been cancelled
Build & Auto-Repair / build (push) Has been cancelled
Hardened db.ts — added statement_timeout: 30000, idle_in_transaction_session_timeout: 10000, created transaction() helper (BEGIN/COMMIT/ROLLBACK). Multi-step API routes wrapped in transactions — Events POST, Conversations POST use atomic blocks. Fixed leads pagination — status filter pushed into SQL WHERE (no client-side filtering after LIMIT/OFFSET), added SELECT COUNT(*) for total. Rewrote dashboard — SQL aggregations (COUNT + GROUP BY + date_trunc) replace loading all leads into memory. Optimized conversations GET — merged duplicate correlated subqueries into single LEFT JOIN LATERAL. Created migration 021_performance_indexes.sql — 8 missing indexes + set_session_user_context() function caching current_user_hierarchy_level() as session variable (avoids per-query RLS join). Fixed build error — duplicate const other in conversations route. Also fixed a TypeScript never error in dashboard breakdown map. Verified — npx tsc --noEmit clean, npm test (13 pass, 7 integration skip gracefully), npx next build succeeds.
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) ==='
|
\echo '=== Running 020_fixes.sql (password_change_required + audit trigger fix) ==='
|
||||||
\i 020_fixes.sql
|
\i 020_fixes.sql
|
||||||
|
|
||||||
|
\echo '=== Running 021_performance_indexes.sql (Performance Indexes + RLS Cache) ==='
|
||||||
|
\i 021_performance_indexes.sql
|
||||||
|
|
||||||
\echo '=== Migration Complete ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
@@ -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
|
export default nextConfig
|
||||||
|
|||||||
Generated
+1499
-11
File diff suppressed because it is too large
Load Diff
+13
-10
@@ -6,22 +6,24 @@
|
|||||||
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
||||||
"dev:signaling": "node signaling-server.mjs",
|
"dev:signaling": "node signaling-server.mjs",
|
||||||
"dev:open": "node scripts/open-browser.mjs",
|
"dev:open": "node scripts/open-browser.mjs",
|
||||||
"dev:repair": "node scripts/code-repair-agent.mjs --watch",
|
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
||||||
"dev:start": "concurrently -n REPAIR,AI,BROWSE,SIGNAL,NEXT,OPEN -c red,cyan,magenta,yellow,green,white \"npm run dev:repair\" \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
|
||||||
"dev:next": "next dev -p 3006",
|
"dev:next": "next dev -p 3006",
|
||||||
"dev:precheck": "node scripts/precheck.mjs && node scripts/run-migrations.mjs",
|
"dev:precheck": "node scripts/precheck.mjs",
|
||||||
"dev:ollama": "node scripts/ensure-ollama.mjs",
|
"dev:ollama": "node scripts/ensure-ollama.mjs",
|
||||||
"dev:rust": "node ai-server/index.mjs",
|
"dev:rust": "node ai-server/index.mjs",
|
||||||
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
|
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
|
||||||
"setup": "node scripts/setup.mjs",
|
"setup": "node scripts/setup.mjs",
|
||||||
"setup:self-heal": "node scripts/setup.mjs --self-heal",
|
"migrate": "node scripts/run-migrations.mjs",
|
||||||
"repair": "node scripts/code-repair-agent.mjs",
|
"db:migrate": "npm run migrate",
|
||||||
"repair:watch": "node scripts/code-repair-agent.mjs --watch",
|
|
||||||
"repair:ci": "node scripts/code-repair-agent.mjs --ci",
|
|
||||||
"build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci",
|
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3006",
|
"start": "next start -p 3006",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"ci": "npm run lint && npx tsc --noEmit && npm test",
|
||||||
|
"repair": "echo 'Auto-repair disabled for security. Fix errors manually.'",
|
||||||
|
"repair:watch": "echo 'Auto-repair disabled for security. Fix errors manually.'",
|
||||||
|
"repair:ci": "echo 'Auto-repair disabled for security. Fix errors manually.'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
@@ -81,6 +83,7 @@
|
|||||||
"maildev": "^2.2.1",
|
"maildev": "^2.2.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^1.6.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-63
@@ -177,8 +177,6 @@ function parseMissingModules(buildOutput) {
|
|||||||
|
|
||||||
// ── Main ───────────────────────────────────────────────────────────
|
// ── Main ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const args = process.argv.slice(2)
|
|
||||||
const doSelfHeal = args.includes("--self-heal") || args.includes("-s")
|
|
||||||
const PY = detectPython()
|
const PY = detectPython()
|
||||||
const PIP = detectPip(PY)
|
const PIP = detectPip(PY)
|
||||||
|
|
||||||
@@ -202,69 +200,11 @@ if (!existsSync(resolve(ROOT, ".env.local"))) {
|
|||||||
console.log("\n── .env.local already exists, skipping ──")
|
console.log("\n── .env.local already exists, skipping ──")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Database migrations (auto-applies on fresh install)
|
// 5. Final summary
|
||||||
console.log("\n── Running Database Migrations ──")
|
|
||||||
try {
|
|
||||||
execSync("node scripts/run-migrations.mjs", { stdio: "inherit", timeout: 30000, cwd: ROOT })
|
|
||||||
console.log(" ✓ Migrations complete")
|
|
||||||
} catch {
|
|
||||||
console.log(" ~ Database not available — run migrations later with: npm run dev")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Self-healing build check (--self-heal flag)
|
|
||||||
if (doSelfHeal) {
|
|
||||||
console.log("\n── Self-Healing Build Check ──")
|
|
||||||
const { generateModule } = loadRegistry()
|
|
||||||
let lastGeneratedCount = -1
|
|
||||||
let iteration = 0
|
|
||||||
const MAX_ITERATIONS = 5
|
|
||||||
|
|
||||||
while (iteration < MAX_ITERATIONS) {
|
|
||||||
iteration++
|
|
||||||
console.log(` Build check pass ${iteration}/${MAX_ITERATIONS}...`)
|
|
||||||
|
|
||||||
let buildOutput = ""
|
|
||||||
try {
|
|
||||||
buildOutput = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT }).toString()
|
|
||||||
} catch (e) {
|
|
||||||
buildOutput = e.stdout?.toString() || e.message || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const missing = parseMissingModules(buildOutput)
|
|
||||||
const internalMissing = missing
|
|
||||||
.filter(m => m.isInternal && m.internalPath)
|
|
||||||
.filter((m, i, arr) => arr.findIndex(x => x.internalPath === m.internalPath) === i) // dedup
|
|
||||||
|
|
||||||
if (internalMissing.length === 0) {
|
|
||||||
console.log(" ✓ No missing internal modules found!")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(` Found ${internalMissing.length} missing internal module(s)`)
|
|
||||||
|
|
||||||
let generated = 0
|
|
||||||
for (const mod of internalMissing) {
|
|
||||||
const result = generateModule(mod.internalPath)
|
|
||||||
if (result.success) {
|
|
||||||
console.log(` ✓ ${result.message}`)
|
|
||||||
generated++
|
|
||||||
} else {
|
|
||||||
console.log(` ✗ ${result.error}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generated === 0 || generated === lastGeneratedCount) {
|
|
||||||
console.log(" No new modules could be generated. Stopping.")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
lastGeneratedCount = generated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Final summary
|
|
||||||
console.log("\n── Final Steps ──")
|
console.log("\n── Final Steps ──")
|
||||||
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
|
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
|
||||||
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
|
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
|
||||||
console.log(" 3. Edit .env.local with your settings")
|
console.log(" 3. Edit .env.local with your settings")
|
||||||
console.log(" 4. Run: npm run dev")
|
console.log(" 4. Run: npm run db:migrate (apply database migrations)")
|
||||||
|
console.log(" 5. Run: npm run dev")
|
||||||
console.log("\n=== Setup complete! ===")
|
console.log("\n=== Setup complete! ===")
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import { SignJWT, jwtVerify } from "jose"
|
|||||||
import pg from "pg"
|
import pg from "pg"
|
||||||
|
|
||||||
const PORT = process.env.SIGNALING_PORT || 3007
|
const PORT = process.env.SIGNALING_PORT || 3007
|
||||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026")
|
const rawSecret = process.env.JWT_SECRET
|
||||||
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm"
|
if (!rawSecret) throw new Error("JWT_SECRET environment variable is required")
|
||||||
|
const JWT_SECRET = new TextEncoder().encode(rawSecret)
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL
|
||||||
|
if (!DATABASE_URL) throw new Error("DATABASE_URL environment variable is required")
|
||||||
|
|
||||||
const pool = new pg.Pool({ connectionString: DATABASE_URL })
|
const pool = new pg.Pool({ connectionString: DATABASE_URL })
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
||||||
|
import { sanitizeSvg } from "@/lib/sanitize"
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
DialogFooter, DialogClose,
|
DialogFooter, DialogClose,
|
||||||
@@ -1041,7 +1042,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
{stickerData.emoji ? (
|
{stickerData.emoji ? (
|
||||||
<span className="text-7xl block text-center">{stickerData.emoji}</span>
|
<span className="text-7xl block text-center">{stickerData.emoji}</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full" dangerouslySetInnerHTML={{ __html: stickerData.url.replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
|
<div className="w-full" dangerouslySetInnerHTML={{ __html: sanitizeSvg(stickerData.url).replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : voiceUrl ? (
|
) : voiceUrl ? (
|
||||||
|
|||||||
@@ -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,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`,
|
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${process.env.NODE_ENV === "production" ? "; Secure" : ""}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query, transaction } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -18,13 +18,18 @@ export async function GET() {
|
|||||||
u.email AS other_user_email,
|
u.email AS other_user_email,
|
||||||
u.phone AS other_user_phone,
|
u.phone AS other_user_phone,
|
||||||
u.avatar_url AS other_user_avatar_url,
|
u.avatar_url AS other_user_avatar_url,
|
||||||
(SELECT content FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message,
|
lm.last_message_data->>'content' AS last_message,
|
||||||
(SELECT created_at FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
(lm.last_message_data->>'created_at')::timestamptz AS last_message_time,
|
||||||
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
|
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
|
||||||
FROM conversations c
|
FROM conversations c
|
||||||
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
|
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
|
||||||
JOIN conversation_participants cp ON cp.conversation_id = c.id
|
JOIN conversation_participants cp ON cp.conversation_id = c.id
|
||||||
JOIN users u ON u.id = cp.user_id AND u.id != $1
|
JOIN users u ON u.id = cp.user_id AND u.id != $1
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT jsonb_build_object('content', content, 'created_at', created_at) AS last_message_data
|
||||||
|
FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
) lm ON true
|
||||||
WHERE c.id IN (
|
WHERE c.id IN (
|
||||||
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
||||||
)
|
)
|
||||||
@@ -80,23 +85,27 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ conversationId: existing.rows[0].id })
|
return NextResponse.json({ conversationId: existing.rows[0].id })
|
||||||
}
|
}
|
||||||
|
|
||||||
const convResult = await query(
|
const result = await transaction(async (client) => {
|
||||||
|
const convResult = await client.query(
|
||||||
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
|
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
|
||||||
)
|
)
|
||||||
const conversationId = convResult.rows[0].id
|
const conversationId = convResult.rows[0].id
|
||||||
|
|
||||||
await query(
|
await client.query(
|
||||||
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
||||||
[conversationId, user.id, userId],
|
[conversationId, user.id, userId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const otherUser = await query(
|
const otherResult = await client.query(
|
||||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||||
FROM users WHERE id = $1`,
|
FROM users WHERE id = $1`,
|
||||||
[userId],
|
[userId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const other = otherUser.rows[0]
|
return { conversationId, other: otherResult.rows[0] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const { conversationId, other } = result
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
conversation: {
|
conversation: {
|
||||||
|
|||||||
+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 } {
|
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
|
||||||
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
|
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
|
||||||
const pct = Math.round(((current - previous) / previous) * 100)
|
const pct = Math.round(((current - previous) / previous) * 100)
|
||||||
return { pct: Math.abs(pct), up: pct >= 0 }
|
return { pct: Math.abs(pct), up: pct >= 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -138,19 +86,107 @@ export async function GET(request: NextRequest) {
|
|||||||
prevRange = getPreviousPeriodRange(period, start)
|
prevRange = getPreviousPeriodRange(period, start)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [currentLeads, prevLeads] = await Promise.all([
|
const ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3"
|
||||||
fetchLeadsInRange(start, end, user.id, isAdmin),
|
|
||||||
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
|
|
||||||
])
|
|
||||||
|
|
||||||
const currentCounts = countStatuses(currentLeads)
|
// Status counts for current period (SQL aggregation)
|
||||||
const prevCounts = countStatuses(prevLeads)
|
const countSql = `
|
||||||
|
SELECT ${stageStatusSql} AS status, COUNT(*) AS count
|
||||||
|
FROM leads l
|
||||||
|
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||||
|
WHERE l.deleted_at IS NULL
|
||||||
|
AND l.created_at >= $1 AND l.created_at <= $2
|
||||||
|
${ownerFilter}
|
||||||
|
GROUP BY ${stageStatusSql}`
|
||||||
|
|
||||||
const totalLeads = currentLeads.length
|
const currentCountRows = await query(
|
||||||
|
countSql,
|
||||||
|
isAdmin
|
||||||
|
? [start.toISOString(), end.toISOString()]
|
||||||
|
: [start.toISOString(), end.toISOString(), user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
const prevCountRows = await query(
|
||||||
|
countSql,
|
||||||
|
isAdmin
|
||||||
|
? [prevRange.start.toISOString(), prevRange.end.toISOString()]
|
||||||
|
: [prevRange.start.toISOString(), prevRange.end.toISOString(), user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
function rowsToCounts(rows: any[]) {
|
||||||
|
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||||
|
for (const r of rows) {
|
||||||
|
const s = r.status as keyof typeof counts
|
||||||
|
if (s in counts) counts[s] = parseInt(r.count, 10)
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCounts = rowsToCounts(currentCountRows.rows)
|
||||||
|
const prevCounts = rowsToCounts(prevCountRows.rows)
|
||||||
|
|
||||||
|
const totalLeads = currentCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0)
|
||||||
|
const prevTotal = prevCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0)
|
||||||
const closedLeads = currentCounts.closed
|
const closedLeads = currentCounts.closed
|
||||||
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
||||||
|
|
||||||
const mappedLeads = currentLeads.map((r: any) => ({
|
// Monthly/weekly breakdown via date_trunc
|
||||||
|
const isMonthly = period === "6months" || period === "12months"
|
||||||
|
const truncUnit = isMonthly ? "month" : "day"
|
||||||
|
const breakdownSql = `
|
||||||
|
SELECT DATE_TRUNC($1, l.created_at) AS period_start,
|
||||||
|
${stageStatusSql} AS status,
|
||||||
|
COUNT(*) AS count
|
||||||
|
FROM leads l
|
||||||
|
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||||
|
WHERE l.deleted_at IS NULL
|
||||||
|
AND l.created_at >= $2 AND l.created_at <= $3
|
||||||
|
${isAdmin ? "" : "AND l.assigned_to = $4"}
|
||||||
|
GROUP BY DATE_TRUNC($1, l.created_at), ${stageStatusSql}
|
||||||
|
ORDER BY period_start ASC`
|
||||||
|
const breakdownParams = isAdmin
|
||||||
|
? [truncUnit, start.toISOString(), end.toISOString()]
|
||||||
|
: [truncUnit, start.toISOString(), end.toISOString(), user.id]
|
||||||
|
const breakdownResult = await query(breakdownSql, breakdownParams)
|
||||||
|
|
||||||
|
// 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,
|
id: r.id,
|
||||||
companyName: r.company_name || "",
|
companyName: r.company_name || "",
|
||||||
contactName: r.contact_name,
|
contactName: r.contact_name,
|
||||||
@@ -158,7 +194,7 @@ export async function GET(request: NextRequest) {
|
|||||||
phone: r.phone || "",
|
phone: r.phone || "",
|
||||||
source: "",
|
source: "",
|
||||||
description: r.notes || "",
|
description: r.notes || "",
|
||||||
status: r.status,
|
status: stageToStatus(r.stage_name),
|
||||||
assignedUserId: r.assigned_to,
|
assignedUserId: r.assigned_to,
|
||||||
assignedUser: r.assigned_to ? {
|
assignedUser: r.assigned_to ? {
|
||||||
id: r.user_id,
|
id: r.user_id,
|
||||||
@@ -170,17 +206,14 @@ export async function GET(request: NextRequest) {
|
|||||||
updatedAt: r.updated_at,
|
updatedAt: r.updated_at,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period, yearParam ? { start, end } : undefined)
|
|
||||||
|
|
||||||
const trends = {
|
const trends = {
|
||||||
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
totalLeads: computeTrend(totalLeads, prevTotal),
|
||||||
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
|
|
||||||
openLeads: computeTrend(currentCounts.open, prevCounts.open),
|
openLeads: computeTrend(currentCounts.open, prevCounts.open),
|
||||||
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
|
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
|
||||||
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
|
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
|
||||||
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
|
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
|
||||||
conversionRate: computeTrend(conversionRate,
|
conversionRate: computeTrend(conversionRate,
|
||||||
prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0),
|
prevTotal > 0 ? Math.round((prevCounts.closed / prevTotal) * 100) : 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
@@ -192,9 +225,9 @@ export async function GET(request: NextRequest) {
|
|||||||
ignoredLeads: currentCounts.ignored,
|
ignoredLeads: currentCounts.ignored,
|
||||||
conversionRate,
|
conversionRate,
|
||||||
monthlyBreakdown,
|
monthlyBreakdown,
|
||||||
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
leadsPerMonth: monthlyBreakdown.map((m) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
||||||
trends,
|
trends,
|
||||||
recentLeads: mappedLeads.slice(0, 10),
|
recentLeads,
|
||||||
statusDistribution: [
|
statusDistribution: [
|
||||||
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
||||||
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
||||||
|
|||||||
+63
-77
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query, transaction } from "@/lib/db"
|
||||||
import { sendEventConfirmation } from "@/lib/email"
|
import { sendEventConfirmation } from "@/lib/email"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 })
|
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await query(
|
// Single transaction for all DB operations
|
||||||
|
const result = await transaction(async (client) => {
|
||||||
|
// 1. Insert event
|
||||||
|
const ins = await client.query(
|
||||||
`INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone)
|
`INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
|
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
|
||||||
[
|
[
|
||||||
user.id,
|
user.id, participantId || null, developerId || null, leadId || null, conversationId || null,
|
||||||
participantId || null,
|
title, description || null, eventType || "website_creation", startTime || null,
|
||||||
developerId || null,
|
|
||||||
leadId || null,
|
|
||||||
conversationId || null,
|
|
||||||
title,
|
|
||||||
description || null,
|
|
||||||
eventType || "website_creation",
|
|
||||||
startTime || null,
|
|
||||||
eventType === "website_creation" ? null : (endTime || null),
|
eventType === "website_creation" ? null : (endTime || null),
|
||||||
eventType === "website_creation" ? null : (durationMinutes || null),
|
eventType === "website_creation" ? null : (durationMinutes || null),
|
||||||
clientName || null,
|
clientName || null, clientEmail || null, clientPhone || null,
|
||||||
clientEmail || null,
|
|
||||||
clientPhone || null,
|
|
||||||
],
|
],
|
||||||
user.id,
|
|
||||||
)
|
)
|
||||||
|
const r = ins.rows[0]
|
||||||
|
|
||||||
const r = result.rows[0]
|
// 2. Auto-update lead stage if website creation event
|
||||||
|
|
||||||
// Auto-update lead to "pending" when a website creation event is created
|
|
||||||
if (r.event_type === "website_creation" && leadId) {
|
if (r.event_type === "website_creation" && leadId) {
|
||||||
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
|
const stageResult = await client.query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
|
||||||
if (stageResult.rows.length > 0) {
|
if (stageResult.rows.length > 0) {
|
||||||
await query(
|
await client.query(
|
||||||
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
|
"UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL",
|
||||||
[stageResult.rows[0].id, leadId],
|
[stageResult.rows[0].id, leadId],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Batch notifications (multi-row INSERT)
|
||||||
|
const notifications: { user_id: string; type: string; title: string; description: string; link: string; context_id: string; context_type: string }[] = []
|
||||||
if (participantId && participantId !== user.id) {
|
if (participantId && participantId !== user.id) {
|
||||||
await query(
|
notifications.push({
|
||||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
user_id: participantId, type: "event_scheduled",
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
title: `Meeting scheduled: ${title}`,
|
||||||
[
|
description: `${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
|
||||||
participantId,
|
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
|
||||||
"event_scheduled",
|
})
|
||||||
`Meeting scheduled: ${title}`,
|
|
||||||
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
|
|
||||||
`/calendar`,
|
|
||||||
r.id,
|
|
||||||
"scheduled_event",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify developer
|
|
||||||
if (developerId && developerId !== user.id) {
|
if (developerId && developerId !== user.id) {
|
||||||
await query(
|
notifications.push({
|
||||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
user_id: developerId, type: "event_scheduled",
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
title: `Project assigned: ${title}`,
|
||||||
[
|
description: `${user.firstName} ${user.lastName} assigned a project to you`,
|
||||||
developerId,
|
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
|
||||||
"event_scheduled",
|
})
|
||||||
`Project assigned: ${title}`,
|
}
|
||||||
`${user.firstName} ${user.lastName} assigned a project to you`,
|
if (notifications.length > 0) {
|
||||||
`/calendar`,
|
const placeholders = notifications.map((_, i) =>
|
||||||
r.id,
|
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7})`
|
||||||
"scheduled_event",
|
).join(", ")
|
||||||
],
|
const flatParams = notifications.flatMap(n => [n.user_id, n.type, n.title, n.description, n.link, n.context_id, n.context_type])
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) VALUES ${placeholders}`,
|
||||||
|
flatParams,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const participantResult = participantId ? await query(
|
// 4. Fetch participant + developer in one query
|
||||||
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
|
const idsToFetch = [participantId, developerId].filter(Boolean) as string[]
|
||||||
[participantId],
|
let usersMap: Record<string, { id: string; first_name: string; last_name: string; email: string }> = {}
|
||||||
) : null
|
if (idsToFetch.length > 0) {
|
||||||
const participant = participantResult?.rows[0] || null
|
const userPlaceholders = idsToFetch.map((_, i) => `$${i + 1}`).join(", ")
|
||||||
|
const userRows = await client.query(
|
||||||
|
`SELECT id, first_name, last_name, email FROM users WHERE id IN (${userPlaceholders})`,
|
||||||
|
idsToFetch,
|
||||||
|
)
|
||||||
|
for (const row of userRows.rows) {
|
||||||
|
usersMap[row.id] = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const developerResult = developerId ? await query(
|
return { r, usersMap }
|
||||||
`SELECT id, first_name, last_name, email FROM users WHERE id = $1`,
|
}, user.id)
|
||||||
[developerId],
|
|
||||||
) : null
|
|
||||||
const dev = developerResult?.rows[0] || null
|
|
||||||
|
|
||||||
|
const { r, usersMap } = result
|
||||||
|
const participant = participantId ? usersMap[participantId] || null : null
|
||||||
|
const dev = developerId ? usersMap[developerId] || null : null
|
||||||
|
|
||||||
|
// Email sent asynchronously outside transaction (side effect)
|
||||||
sendEventConfirmation({
|
sendEventConfirmation({
|
||||||
creatorName: `${user.firstName} ${user.lastName}`,
|
creatorName: `${user.firstName} ${user.lastName}`,
|
||||||
creatorEmail: user.email,
|
creatorEmail: user.email,
|
||||||
@@ -215,28 +213,16 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
event: {
|
event: {
|
||||||
id: r.id,
|
id: r.id, userId: r.user_id, participantId: r.participant_id, developerId: r.developer_id,
|
||||||
userId: r.user_id,
|
leadId: r.lead_id, conversationId: r.conversation_id,
|
||||||
participantId: r.participant_id,
|
title: r.title, description: r.description, participantNotes: r.participant_notes,
|
||||||
developerId: r.developer_id,
|
eventType: r.event_type, startTime: r.start_time, endTime: r.end_time,
|
||||||
leadId: r.lead_id,
|
durationMinutes: r.duration_minutes, status: r.status, createdAt: r.created_at,
|
||||||
conversationId: r.conversation_id,
|
|
||||||
title: r.title,
|
|
||||||
description: r.description,
|
|
||||||
participantNotes: r.participant_notes,
|
|
||||||
eventType: r.event_type,
|
|
||||||
startTime: r.start_time,
|
|
||||||
endTime: r.end_time,
|
|
||||||
durationMinutes: r.duration_minutes,
|
|
||||||
status: r.status,
|
|
||||||
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
|
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
|
||||||
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
|
participant: participant ? { id: participantId, name: `${participant.first_name} ${participant.last_name}`, email: participant.email } : null,
|
||||||
developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null,
|
developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null,
|
||||||
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
|
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
|
||||||
clientName: r.client_name || null,
|
clientName: r.client_name || null, clientEmail: r.client_email || null, clientPhone: r.client_phone || null,
|
||||||
clientEmail: r.client_email || null,
|
|
||||||
clientPhone: r.client_phone || null,
|
|
||||||
createdAt: r.created_at,
|
|
||||||
},
|
},
|
||||||
}, { status: 201 })
|
}, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,15 +1,38 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser, setSessionContext } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import crypto from "crypto"
|
import crypto from "crypto"
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { phone, token: clientToken } = await request.json()
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Only SUPER_ADMIN can generate invites." }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { phone } = await request.json()
|
||||||
if (!phone) {
|
if (!phone) {
|
||||||
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
|
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = clientToken || crypto.randomBytes(24).toString("hex")
|
const ipAddress =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"127.0.0.1"
|
||||||
|
|
||||||
|
await setSessionContext(sessionUser.id, ipAddress)
|
||||||
|
|
||||||
|
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(
|
await query(
|
||||||
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
||||||
[token, phone],
|
[token, phone],
|
||||||
@@ -19,7 +42,8 @@ export async function POST(request: NextRequest) {
|
|||||||
const inviteUrl = `${origin}/join/${token}`
|
const inviteUrl = `${origin}/join/${token}`
|
||||||
|
|
||||||
return NextResponse.json({ token, inviteUrl })
|
return NextResponse.json({ token, inviteUrl })
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("Invite generation error:", error)
|
||||||
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-23
@@ -43,47 +43,74 @@ export async function GET(request: NextRequest) {
|
|||||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
|
||||||
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
|
||||||
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[] = []
|
const params: any[] = []
|
||||||
let paramIdx = 1
|
let paramIdx = 1
|
||||||
|
|
||||||
|
const conditions: string[] = ["l.deleted_at IS NULL"]
|
||||||
|
|
||||||
if (period !== "all") {
|
if (period !== "all") {
|
||||||
const range = getPeriodDateRange(period)
|
const range = getPeriodDateRange(period)
|
||||||
if (range) {
|
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())
|
params.push(range.start.toISOString(), range.end.toISOString())
|
||||||
paramIdx += 2
|
paramIdx += 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
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}%`)
|
params.push(`%${search}%`)
|
||||||
paramIdx++
|
paramIdx++
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
sql += ` AND l.assigned_to = $${paramIdx}`
|
conditions.push(`l.assigned_to = $${paramIdx}`)
|
||||||
params.push(user.id)
|
params.push(user.id)
|
||||||
paramIdx++
|
paramIdx++
|
||||||
}
|
}
|
||||||
|
|
||||||
sql += ` ORDER BY l.created_at DESC`
|
// Push status filter into SQL (avoid client-side filtering after pagination)
|
||||||
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
|
const statusStageMap: Record<string, string[]> = {
|
||||||
params.push(limit, offset)
|
open: ["New"],
|
||||||
paramIdx += 2
|
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)
|
const s = stageToStatus(r.stage_name)
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -106,11 +133,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (status !== "all") {
|
return NextResponse.json({ leads, total })
|
||||||
leads = leads.filter((l: any) => l.status === status)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(leads)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Leads API error:", error)
|
console.error("Leads API error:", error)
|
||||||
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionUser.role !== "super_admin" && sessionUser.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const phone = request.nextUrl.searchParams.get("phone")
|
const phone = request.nextUrl.searchParams.get("phone")
|
||||||
if (!phone) {
|
if (!phone) {
|
||||||
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Search, Image, Sticker, X, Loader2, TrendingUp } from "lucide-react"
|
|||||||
import data from "@emoji-mart/data"
|
import data from "@emoji-mart/data"
|
||||||
import Picker from "@emoji-mart/react"
|
import Picker from "@emoji-mart/react"
|
||||||
import { getStickerPacks, type StickerPack } from "@/data/stickers"
|
import { getStickerPacks, type StickerPack } from "@/data/stickers"
|
||||||
|
import { sanitizeSvg } from "@/lib/sanitize"
|
||||||
|
|
||||||
type Tab = "emoji" | "gif" | "sticker"
|
type Tab = "emoji" | "gif" | "sticker"
|
||||||
|
|
||||||
@@ -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"
|
className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square"
|
||||||
>
|
>
|
||||||
{sticker.svg ? (
|
{sticker.svg ? (
|
||||||
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sticker.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
|
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sanitizeSvg(sticker.svg).replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-5xl">{sticker.emoji}</span>
|
<span className="text-5xl">{sticker.emoji}</span>
|
||||||
)}
|
)}
|
||||||
@@ -303,7 +304,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
|||||||
if (!s) return null
|
if (!s) return null
|
||||||
return (
|
return (
|
||||||
<button key={s.id} type="button" onClick={() => handleStickerSelect(s)} className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square opacity-70 hover:opacity-100">
|
<button key={s.id} type="button" onClick={() => handleStickerSelect(s)} className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square opacity-70 hover:opacity-100">
|
||||||
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: s.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
|
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sanitizeSvg(s.svg).replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
+2
-33
@@ -1,21 +1,7 @@
|
|||||||
import { SignJWT, jwtVerify } from "jose";
|
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { signToken, verifyToken } from "@/lib/jwt";
|
||||||
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);
|
|
||||||
|
|
||||||
export const SESSION_COOKIE = "session";
|
export const SESSION_COOKIE = "session";
|
||||||
const MAX_FAILED_ATTEMPTS = 5;
|
const MAX_FAILED_ATTEMPTS = 5;
|
||||||
@@ -43,23 +29,6 @@ export async function comparePassword(
|
|||||||
return bcrypt.compare(password, hash);
|
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) {
|
export async function getUserByEmail(email: string) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
` 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) {
|
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) {
|
if (ip) {
|
||||||
await query("SELECT set_config('app.current_ip', $1, true)", [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
|
const dbUrl = process.env.DATABASE_URL
|
||||||
if (!dbUrl) {
|
if (!dbUrl) {
|
||||||
@@ -9,6 +9,8 @@ const pool = new Pool({
|
|||||||
max: 20,
|
max: 20,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
connectionTimeoutMillis: 5000,
|
connectionTimeoutMillis: 5000,
|
||||||
|
statement_timeout: 30000,
|
||||||
|
idle_in_transaction_session_timeout: 10000,
|
||||||
ssl: dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1") ? false : { rejectUnauthorized: false },
|
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
|
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 { NextResponse } from "next/server"
|
||||||
import type { NextRequest } from "next/server"
|
import type { NextRequest } from "next/server"
|
||||||
|
import { verifyToken } from "@/lib/jwt"
|
||||||
|
|
||||||
const publicRoutes = [
|
const publicRoutes = [
|
||||||
"/login",
|
"/login",
|
||||||
@@ -12,6 +13,8 @@ const publicRoutes = [
|
|||||||
"/fonts",
|
"/fonts",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const SESSION_COOKIE = "session"
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
@@ -23,9 +26,9 @@ export async function middleware(request: NextRequest) {
|
|||||||
return NextResponse.next()
|
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/")) {
|
if (pathname.startsWith("/api/")) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,21 @@ export async function middleware(request: NextRequest) {
|
|||||||
return NextResponse.redirect(loginUrl)
|
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 = {
|
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