Current state
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
|
||||
|
||||
export async function chatWithAI(message: string, jwtToken: string) {
|
||||
const res = await fetch(`${AI_SERVICE}/ai/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
|
||||
body: JSON.stringify({ message }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`AI service error (${res.status}): ${text}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return data.response || ""
|
||||
}
|
||||
|
||||
export async function fetchJobs() {
|
||||
try {
|
||||
const res = await fetch(`${AI_SERVICE}/ai/jobs`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.jobs || []
|
||||
} catch {
|
||||
console.warn("Failed to fetch AI jobs")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAiServiceStatus() {
|
||||
try {
|
||||
const res = await fetch(`${AI_SERVICE}/health`)
|
||||
if (!res.ok) return false
|
||||
const data = await res.json()
|
||||
return data.status === "ok"
|
||||
} catch {
|
||||
console.warn("Failed to check AI service status")
|
||||
return false
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { query } from "@/lib/db";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const RAW_SECRET = process.env.JWT_SECRET;
|
||||
if (!RAW_SECRET) {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
|
||||
|
||||
const SESSION_COOKIE = "session";
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
const LOCKOUT_DURATION_MINUTES = 15;
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
export async function comparePassword(
|
||||
password: string,
|
||||
hash: string,
|
||||
): Promise<boolean> {
|
||||
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,
|
||||
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
|
||||
u.password_change_required, u.avatar_url,
|
||||
r.name AS role_name
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE u.email = $1 AND u.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[email.toLowerCase().trim()],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getUserByUsername(username: string) {
|
||||
const result = await query(
|
||||
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
|
||||
u.password_change_required, u.avatar_url,
|
||||
r.name AS role_name
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE u.username = $1 AND u.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[username.toLowerCase().trim()],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
const result = await query(
|
||||
` SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
||||
u.is_active, u.avatar_url,
|
||||
r.name AS role_name
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE u.id = $1 AND u.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
export async function recordLoginAttempt(
|
||||
userId: string | null,
|
||||
usernameAttempted: string,
|
||||
ipAddress: string,
|
||||
userAgent: string | null,
|
||||
wasSuccessful: boolean,
|
||||
failureReason?: string,
|
||||
) {
|
||||
await query(
|
||||
`INSERT INTO login_attempts (user_id, username_attempted, ip_address, user_agent, was_successful, failure_reason)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
userId,
|
||||
usernameAttempted,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
wasSuccessful,
|
||||
failureReason || null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function incrementFailedAttempts(userId: string) {
|
||||
await query(
|
||||
`UPDATE users
|
||||
SET failed_login_attempts = failed_login_attempts + 1,
|
||||
locked_until = CASE
|
||||
WHEN failed_login_attempts + 1 >= $2
|
||||
THEN NOW() + (INTERVAL '1 minute' * $3)
|
||||
ELSE locked_until
|
||||
END
|
||||
WHERE id = $1`,
|
||||
[userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES],
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetFailedAttempts(userId: string) {
|
||||
await query(
|
||||
`UPDATE users
|
||||
SET failed_login_attempts = 0,
|
||||
locked_until = NULL,
|
||||
last_login_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
}
|
||||
|
||||
export async function isAccountLocked(user: {
|
||||
is_locked: boolean;
|
||||
locked_until: Date | null;
|
||||
}): Promise<{ locked: boolean; reason?: string }> {
|
||||
if (user.is_locked) {
|
||||
return {
|
||||
locked: true,
|
||||
reason: "Account has been locked by an administrator.",
|
||||
};
|
||||
}
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
const minutes = Math.ceil(
|
||||
(new Date(user.locked_until).getTime() - Date.now()) / 60000,
|
||||
);
|
||||
return {
|
||||
locked: true,
|
||||
reason: `Account is temporarily locked due to too many failed attempts. Try again in ${minutes} minute(s).`,
|
||||
};
|
||||
}
|
||||
return { locked: false };
|
||||
}
|
||||
|
||||
export function mapDbUserToSessionUser(
|
||||
dbUser: Record<string, unknown>,
|
||||
): SessionUser {
|
||||
const roleName = dbUser.role_name as string;
|
||||
|
||||
const roleMapping: Record<string, string> = {
|
||||
SUPER_ADMIN: "super_admin",
|
||||
ADMIN: "admin",
|
||||
SALES_USER: "sales",
|
||||
DEVELOPER: "dev",
|
||||
};
|
||||
|
||||
const avatarUrl = dbUser.avatar_url as string | null
|
||||
|
||||
return {
|
||||
id: dbUser.id as string,
|
||||
username: dbUser.username as string,
|
||||
email: dbUser.email as string,
|
||||
firstName: dbUser.first_name as string,
|
||||
lastName: dbUser.last_name as string,
|
||||
role: roleMapping[roleName] || roleName.toLowerCase(),
|
||||
avatar: avatarUrl || (() => { const f = ((dbUser.first_name as string)?.[0] || '').toUpperCase(); const l = ((dbUser.last_name as string)?.[0] || '').toUpperCase(); return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><rect width="128" height="128" fill="#1d4ed8"/><text x="64" y="80" font-size="48" fill="white" text-anchor="middle" font-family="Arial,Helvetica,sans-serif">${f}${l}</text></svg>`)}` })(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSession(userId: string, role: string) {
|
||||
const token = await signToken({ userId, role });
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function destroySession() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
const payload = await verifyToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
const dbUser = await getUserById(payload.userId);
|
||||
if (!dbUser) return null;
|
||||
|
||||
return mapDbUserToSessionUser(dbUser);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
function sanitizeName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9\s\-'.-]/g, "").slice(0, 50)
|
||||
}
|
||||
|
||||
export function getInitials(name: string): string {
|
||||
return sanitizeName(name)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
export function avatarSvgUrl(name: string, bg = "1d4ed8", size = 128): string {
|
||||
const initials = getInitials(name)
|
||||
const fontSize = Math.round(size * 0.375)
|
||||
const y = Math.round(size * 0.625)
|
||||
return `data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" fill="#${bg}"/><text x="${Math.round(size / 2)}" y="${y}" font-size="${fontSize}" fill="white" text-anchor="middle" font-family="Arial,Helvetica,sans-serif">${initials}</text></svg>`
|
||||
)}`
|
||||
}
|
||||
|
||||
export function getAvatarUrl(name: string, avatarUrl: string | null | undefined, bg = "1d4ed8", size = 128): string {
|
||||
return avatarUrl || avatarSvgUrl(name, bg, size)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
export const BLOCKED_CODE_EXTENSIONS = new Set([
|
||||
"py", "pyc", "pyo", "pyw", "pyx", "ipynb",
|
||||
"js", "mjs", "cjs", "jsx", "ts", "tsx",
|
||||
"php", "phtml", "php3", "php4", "php5",
|
||||
"rb", "erb",
|
||||
"pl", "pm",
|
||||
"lua",
|
||||
"sh", "bash", "zsh", "ksh", "fish",
|
||||
"ps1", "psm1", "psd1",
|
||||
"bat", "cmd",
|
||||
"vbs",
|
||||
"applescript",
|
||||
"ahk",
|
||||
"au3",
|
||||
"tcl",
|
||||
"c", "h", "cpp", "cc", "cxx", "hpp", "hh", "hxx",
|
||||
"cs", "vb",
|
||||
"java", "class", "jar",
|
||||
"go",
|
||||
"rs",
|
||||
"swift",
|
||||
"kt", "kts",
|
||||
"scala",
|
||||
"groovy", "gradle",
|
||||
"d",
|
||||
"nim",
|
||||
"cr",
|
||||
"zig",
|
||||
"v",
|
||||
"vhd", "vhdl",
|
||||
"asm", "s",
|
||||
"f", "f90", "f95", "for",
|
||||
"pas", "pp",
|
||||
"ada", "adb", "ads",
|
||||
"cob", "cbl",
|
||||
"hs", "lhs",
|
||||
"ml", "mli",
|
||||
"fs", "fsx", "fsi",
|
||||
"clj", "cljs", "cljc", "edn",
|
||||
"lisp", "lsp",
|
||||
"scm", "ss",
|
||||
"rkt",
|
||||
"ex", "exs",
|
||||
"erl", "hrl",
|
||||
"jl",
|
||||
"elm",
|
||||
"purs",
|
||||
"re", "rei",
|
||||
"sol",
|
||||
"move",
|
||||
"pro",
|
||||
"m",
|
||||
"html", "htm",
|
||||
"css", "scss", "sass", "less",
|
||||
"vue", "svelte",
|
||||
"json5",
|
||||
"o", "so", "dll", "exe", "dylib",
|
||||
"wasm", "wat", "bin",
|
||||
"out",
|
||||
"deb", "rpm",
|
||||
"app", "apk", "ipa",
|
||||
"sql", "psql",
|
||||
"ino", "pde",
|
||||
"sas", "do",
|
||||
])
|
||||
|
||||
export function getAllExtensions(name: string): string[] {
|
||||
const parts = name.split(".")
|
||||
if (parts.length <= 1) return []
|
||||
return parts.slice(1).map((ext) => ext.toLowerCase())
|
||||
}
|
||||
|
||||
export function hasBlockedCodeExtension(name: string): boolean {
|
||||
const exts = getAllExtensions(name)
|
||||
return exts.some((ext) => BLOCKED_CODE_EXTENSIONS.has(ext))
|
||||
}
|
||||
|
||||
export function filterBlockedFiles<T extends { name: string }>(
|
||||
files: T[],
|
||||
): { allowed: T[]; rejected: T[] } {
|
||||
const allowed: T[] = []
|
||||
const rejected: T[] = []
|
||||
for (const f of files) {
|
||||
if (hasBlockedCodeExtension(f.name)) {
|
||||
rejected.push(f)
|
||||
} else {
|
||||
allowed.push(f)
|
||||
}
|
||||
}
|
||||
return { allowed, rejected }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const LEAD_STATUSES = {
|
||||
open: { label: "Open", color: "bg-blue-500" },
|
||||
contacted: { label: "Contacted", color: "bg-amber-500" },
|
||||
pending: { label: "Pending", color: "bg-purple-500" },
|
||||
closed: { label: "Closed", color: "bg-emerald-500" },
|
||||
ignored: { label: "Ignored", color: "bg-zinc-500" },
|
||||
} as const
|
||||
|
||||
export const LEAD_SOURCES = [
|
||||
"Website",
|
||||
"Referral",
|
||||
"LinkedIn",
|
||||
"Cold Call",
|
||||
"Email",
|
||||
"Google",
|
||||
"Social Media",
|
||||
"Other",
|
||||
] as const
|
||||
|
||||
export const ITEMS_PER_PAGE = 10
|
||||
|
||||
export const COMPANY_NAME = "Coast IT"
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Lead } from "@/types"
|
||||
|
||||
export function getPeriodDateRange(period: string): { start: Date; end: Date } {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
|
||||
switch (period) {
|
||||
case "7days":
|
||||
start.setDate(start.getDate() - 7)
|
||||
break
|
||||
case "30days":
|
||||
start.setDate(start.getDate() - 30)
|
||||
break
|
||||
case "6months":
|
||||
start.setMonth(start.getMonth() - 6)
|
||||
break
|
||||
case "12months":
|
||||
start.setMonth(start.getMonth() - 12)
|
||||
break
|
||||
default:
|
||||
start.setMonth(start.getMonth() - 6)
|
||||
}
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export function filterLeadsByPeriod(leads: Lead[], period: string): Lead[] {
|
||||
const { start, end } = getPeriodDateRange(period)
|
||||
return leads.filter((l) => {
|
||||
const d = new Date(l.createdAt)
|
||||
return d >= start && d <= end
|
||||
})
|
||||
}
|
||||
|
||||
export function groupLeadsByInterval(
|
||||
leads: Lead[],
|
||||
period: string
|
||||
): { label: string; leads: number; closed: number }[] {
|
||||
const { start, end } = getPeriodDateRange(period)
|
||||
|
||||
if (period === "7days" || period === "30days") {
|
||||
return groupByDay(leads, start, end)
|
||||
}
|
||||
return groupByMonth(leads, start, end)
|
||||
}
|
||||
|
||||
function groupByDay(
|
||||
leads: Lead[],
|
||||
start: Date,
|
||||
end: Date
|
||||
): { label: string; leads: number; closed: number }[] {
|
||||
const result: { label: string; leads: number; closed: number }[] = []
|
||||
const current = new Date(start)
|
||||
|
||||
while (current <= end) {
|
||||
const dateStr = current.toISOString().slice(0, 10)
|
||||
const dayLeads = leads.filter((l) => l.createdAt.startsWith(dateStr))
|
||||
const closedCount = dayLeads.filter((l) => l.status === "closed").length
|
||||
|
||||
result.push({
|
||||
label: current.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
||||
leads: dayLeads.length,
|
||||
closed: closedCount,
|
||||
})
|
||||
|
||||
current.setDate(current.getDate() + 1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function groupByMonth(
|
||||
leads: Lead[],
|
||||
start: Date,
|
||||
end: Date
|
||||
): { label: string; leads: number; closed: number }[] {
|
||||
const result: { label: string; leads: number; closed: number }[] = []
|
||||
const current = new Date(start.getFullYear(), start.getMonth(), 1)
|
||||
|
||||
while (current <= end) {
|
||||
const yearMonth = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}`
|
||||
const monthLeads = leads.filter((l) => l.createdAt.startsWith(yearMonth))
|
||||
const closedCount = monthLeads.filter((l) => l.status === "closed").length
|
||||
|
||||
result.push({
|
||||
label: current.toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
|
||||
leads: monthLeads.length,
|
||||
closed: closedCount,
|
||||
})
|
||||
|
||||
current.setMonth(current.getMonth() + 1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Pool } from "pg"
|
||||
|
||||
const dbUrl = process.env.DATABASE_URL
|
||||
if (!dbUrl) {
|
||||
throw new Error("DATABASE_URL environment variable is required")
|
||||
}
|
||||
const pool = new Pool({
|
||||
connectionString: dbUrl,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
ssl: dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1") ? false : { rejectUnauthorized: false },
|
||||
})
|
||||
|
||||
pool.on("error", (err) => {
|
||||
console.error("Unexpected database pool error:", err)
|
||||
})
|
||||
|
||||
export async function query(text: string, params?: unknown[]) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
const result = await client.query(text, params)
|
||||
return result
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
export default pool
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js"
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ""
|
||||
|
||||
let client: SupabaseClient | null = null
|
||||
|
||||
if (supabaseUrl && supabaseAnonKey) {
|
||||
client = createClient(supabaseUrl, supabaseAnonKey)
|
||||
}
|
||||
|
||||
export function getSupabase(): SupabaseClient {
|
||||
if (!client) {
|
||||
throw new Error("Supabase is not configured. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.")
|
||||
}
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user