feat: add authentication, user management, and admin features

Added comprehensive authentication and authorization system:

Authentication System:
- Better Auth integration with session management
- Login/logout pages and API routes
- Middleware for route protection
- Auth utilities and client libraries

User Management:
- User list, detail, and invite pages
- User API endpoints (CRUD operations)
- Session management for users
- Profile settings page

Role-Based Access Control:
- Role management pages (list, create, edit)
- Permission system with granular controls
- Role assignment to users
- Role API endpoints

Admin Features:
- Audit log page for tracking system events
- Admin settings page
- Audit service for logging user actions

Additional Features:
- Quotes management pages and components
- SalesBldr API integration
- Email service for notifications

Configuration & Documentation:
- Updated docker-compose.yml
- MCP server configuration (mcp.json)
- CVE-2025-55182 security review documentation
- Standards guide and PRD documents
- Re-enabling authentication documentation

Database Migrations:
- 012: Auth tables (users, sessions, accounts, verifications)
- 013: Role tables (roles, permissions, role_permissions, user_roles)
- 014: Admin settings table

UI Updates:
- Updated dashboard layout
- Enhanced app layout with auth integration
This commit is contained in:
root 2026-01-31 12:43:14 -05:00
parent d8e6931b85
commit 9f912aed24
68 changed files with 7651 additions and 3 deletions

40
lib/auth-client.ts Normal file
View file

@ -0,0 +1,40 @@
import { createAuthClient } from "better-auth/react";
import { magicLinkClient, twoFactorClient, adminClient } from "better-auth/client/plugins";
import { ac, adminRole, userRole, superAdminRole } from "./permissions";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "",
plugins: [
// Magic link client
magicLinkClient(),
// Two-factor authentication client
twoFactorClient({
onTwoFactorRedirect() {
window.location.href = "/auth/2fa";
},
}),
// Admin client with RBAC
adminClient({
ac,
roles: {
"super-admin": superAdminRole,
admin: adminRole,
user: userRole,
},
}),
],
});
// Export commonly used hooks and methods
export const {
signIn,
signOut,
useSession,
getSession,
} = authClient;
// Type exports
export type Session = typeof authClient.$Infer.Session;
export type User = typeof authClient.$Infer.Session.user;

169
lib/auth-utils.ts Normal file
View file

@ -0,0 +1,169 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "./auth";
import { hasPermission, type Permission } from "./permissions";
// Extended user type with custom fields
type UserWithRole = {
id: string;
email: string;
name: string;
role?: string;
requires_setup?: boolean;
[key: string]: unknown;
};
/**
* Get the current session from the request headers
* Use this in API routes and server components
*/
export async function getSession() {
const session = await auth.api.getSession({
headers: await headers(),
});
return session;
}
/**
* Require authentication for an API route
* Returns the session if authenticated, or a 401 response if not
*/
export async function requireAuth() {
const session = await getSession();
if (!session) {
return {
session: null,
error: NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
),
};
}
return { session, error: null };
}
/**
* Require specific permissions for an API route
* Returns the session if authorized, or a 403 response if not
*/
export async function requirePermission(
resource: Permission["resource"],
action: string
) {
const { session, error } = await requireAuth();
if (error) {
return { session: null, error };
}
const userRole = (session!.user as UserWithRole).role || "user";
if (!hasPermission(userRole, resource, action)) {
return {
session: null,
error: NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
),
};
}
return { session, error: null };
}
/**
* Require admin or super-admin role
*/
export async function requireAdmin() {
const { session, error } = await requireAuth();
if (error) {
return { session: null, error };
}
const userRole = (session!.user as UserWithRole).role || "user";
if (userRole !== "admin" && userRole !== "super-admin") {
return {
session: null,
error: NextResponse.json(
{ error: "Forbidden - Admin access required" },
{ status: 403 }
),
};
}
return { session, error: null };
}
/**
* Require super-admin role
*/
export async function requireSuperAdmin() {
const { session, error } = await requireAuth();
if (error) {
return { session: null, error };
}
const userRole = (session!.user as UserWithRole).role || "user";
if (userRole !== "super-admin") {
return {
session: null,
error: NextResponse.json(
{ error: "Forbidden - Super admin access required" },
{ status: 403 }
),
};
}
return { session, error: null };
}
/**
* Check if the current user has a specific permission
* Use this in server components for conditional rendering
*/
export async function checkPermission(
resource: Permission["resource"],
action: string
): Promise<boolean> {
const session = await getSession();
if (!session) {
return false;
}
const userRole = (session.user as UserWithRole).role || "user";
return hasPermission(userRole, resource, action);
}
/**
* Check if the current user is an admin or super-admin
*/
export async function isAdmin(): Promise<boolean> {
const session = await getSession();
if (!session) {
return false;
}
const userRole = (session.user as UserWithRole).role || "user";
return userRole === "admin" || userRole === "super-admin";
}
/**
* Check if the current user is a super-admin
*/
export async function isSuperAdmin(): Promise<boolean> {
const session = await getSession();
if (!session) {
return false;
}
return (session.user as UserWithRole).role === "super-admin";
}

92
lib/auth.ts Normal file
View file

@ -0,0 +1,92 @@
import { betterAuth } from "better-auth";
import { magicLink, twoFactor, admin } from "better-auth/plugins";
import { nextCookies } from "better-auth/next-js";
import { Pool } from "pg";
import { sendMagicLinkEmail } from "./services/email";
import { ac, adminRole, userRole, superAdminRole } from "./permissions";
// Create a PostgreSQL pool for Better Auth
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const auth = betterAuth({
database: pool,
appName: "Pulse",
baseURL: process.env.BETTER_AUTH_URL,
secret: process.env.BETTER_AUTH_SECRET,
trustedOrigins: [
"http://localhost:3100",
"https://pulse.wulfconsulting.cloud"
],
// Email & Password disabled - using magic link and Microsoft OAuth only
emailAndPassword: {
enabled: false,
},
// Session configuration
session: {
expiresIn: parseInt(process.env.SESSION_TIMEOUT_SECONDS || "86400"),
updateAge: 60 * 60, // Update session every hour
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes
},
},
// Social providers
socialProviders: {
microsoft: {
clientId: process.env.MICROSOFT_CLIENT_ID || "",
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
tenantId: process.env.MICROSOFT_TENANT_ID || "common",
},
},
// Plugins
plugins: [
// Magic link authentication
magicLink({
sendMagicLink: async ({ email, url, token }) => {
await sendMagicLinkEmail({ email, url, token });
},
expiresIn: 300, // 5 minutes
}),
// Two-factor authentication
twoFactor({
issuer: "Pulse",
}),
// Admin plugin with RBAC
admin({
ac,
roles: {
"super-admin": superAdminRole,
admin: adminRole,
user: userRole,
},
}),
// Next.js cookie handling - must be last
nextCookies(),
],
// User configuration
user: {
additionalFields: {
role: {
type: "string",
defaultValue: "user",
},
requires_setup: {
type: "boolean",
defaultValue: false,
},
},
},
});
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;

103
lib/bootstrap.ts Normal file
View file

@ -0,0 +1,103 @@
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
/**
* Check if any users exist in the database
*/
export async function hasUsers(): Promise<boolean> {
try {
const result = await pool.query('SELECT COUNT(*) as count FROM "user"');
return parseInt(result.rows[0].count) > 0;
} catch (error) {
// Table might not exist yet
return false;
}
}
/**
* Create the default super-admin user from environment variables
*/
export async function createDefaultAdmin(): Promise<boolean> {
const email = process.env.DEFAULT_ADMIN_EMAIL;
const name = process.env.DEFAULT_ADMIN_NAME || "System Administrator";
if (!email) {
console.warn("DEFAULT_ADMIN_EMAIL not set, skipping admin creation");
return false;
}
try {
// Check if user already exists
const existing = await pool.query(
'SELECT id FROM "user" WHERE email = $1',
[email]
);
if (existing.rows.length > 0) {
console.log("Default admin already exists");
return false;
}
// Create the admin user
const id = crypto.randomUUID();
await pool.query(
`INSERT INTO "user" (id, name, email, role, email_verified, requires_setup, created_at, updated_at)
VALUES ($1, $2, $3, 'super-admin', false, true, NOW(), NOW())`,
[id, name, email]
);
console.log(`Created default admin: ${email}`);
return true;
} catch (error) {
console.error("Failed to create default admin:", error);
return false;
}
}
/**
* Run bootstrap checks and setup
*/
export async function bootstrap(): Promise<void> {
try {
const usersExist = await hasUsers();
if (!usersExist) {
console.log("No users found, running initial setup...");
await createDefaultAdmin();
}
} catch (error) {
console.error("Bootstrap failed:", error);
}
}
/**
* Check if a user requires setup (has requires_setup flag)
*/
export async function userRequiresSetup(userId: string): Promise<boolean> {
try {
const result = await pool.query(
'SELECT requires_setup FROM "user" WHERE id = $1',
[userId]
);
return result.rows[0]?.requires_setup === true;
} catch (error) {
return false;
}
}
/**
* Clear the requires_setup flag for a user
*/
export async function clearSetupFlag(userId: string): Promise<void> {
try {
await pool.query(
'UPDATE "user" SET requires_setup = false, updated_at = NOW() WHERE id = $1',
[userId]
);
} catch (error) {
console.error("Failed to clear setup flag:", error);
}
}

97
lib/permissions.ts Normal file
View file

@ -0,0 +1,97 @@
import { createAccessControl } from "better-auth/plugins/access";
// Define all available permissions for resources
export const statement = {
// Ticket management
tickets: ["create", "read", "update", "delete"],
// Configuration items
configItems: ["create", "read", "update", "delete"],
// Admin panel access
admin: ["access"],
// User management
users: ["create", "read", "update", "delete", "invite", "ban"],
// Role management
roles: ["create", "read", "update", "delete"],
// Audit log access
auditLog: ["read"],
// Settings management
settings: ["read", "update"],
} as const;
// Create access control instance
export const ac = createAccessControl(statement);
// Super Admin role - full access to everything
export const superAdminRole = ac.newRole({
tickets: ["create", "read", "update", "delete"],
configItems: ["create", "read", "update", "delete"],
admin: ["access"],
users: ["create", "read", "update", "delete", "invite", "ban"],
roles: ["create", "read", "update", "delete"],
auditLog: ["read"],
settings: ["read", "update"],
});
// Admin role - access to admin panel and user management, but not role management
export const adminRole = ac.newRole({
tickets: ["create", "read", "update", "delete"],
configItems: ["create", "read", "update", "delete"],
admin: ["access"],
users: ["create", "read", "update", "invite"],
roles: ["read"],
auditLog: ["read"],
settings: ["read"],
});
// User role - basic access
export const userRole = ac.newRole({
tickets: ["create", "read", "update"],
configItems: ["read"],
admin: [],
users: [],
roles: [],
auditLog: [],
settings: [],
});
// Helper function to check if a user has a specific permission
export function hasPermission(
userRole: string,
resource: keyof typeof statement,
action: string
): boolean {
const roles: Record<string, ReturnType<typeof ac.newRole>> = {
"super-admin": superAdminRole,
admin: adminRole,
user: userRole as unknown as ReturnType<typeof ac.newRole>,
};
const role = roles[userRole];
if (!role) return false;
// Check if the role has the permission
const permissions = role.statements[resource];
if (!permissions) return false;
return (permissions as readonly string[]).includes(action);
}
// Type for permission check
export type Permission = {
resource: keyof typeof statement;
action: string;
};
// Check multiple permissions
export function hasPermissions(
userRole: string,
permissions: Permission[]
): boolean {
return permissions.every((p) => hasPermission(userRole, p.resource, p.action));
}

197
lib/services/audit.ts Normal file
View file

@ -0,0 +1,197 @@
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export interface AuditLogEntry {
userId?: string;
userEmail?: string;
action: string;
resource: string;
resourceId?: string;
details?: Record<string, unknown>;
ipAddress?: string;
userAgent?: string;
}
/**
* Log an audit event
*/
export async function log(entry: AuditLogEntry): Promise<void> {
try {
const id = crypto.randomUUID();
await pool.query(
`INSERT INTO "audit_log"
(id, user_id, user_email, action, resource, resource_id, details, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
id,
entry.userId || null,
entry.userEmail || null,
entry.action,
entry.resource,
entry.resourceId || null,
entry.details ? JSON.stringify(entry.details) : null,
entry.ipAddress || null,
entry.userAgent || null,
]
);
} catch (error) {
console.error("Failed to write audit log:", error);
// Don't throw - audit logging should not break the main flow
}
}
// Convenience functions for common audit events
export const audit = {
log,
// Auth events
signIn: (userId: string, email: string, ipAddress?: string, userAgent?: string) =>
log({
userId,
userEmail: email,
action: "sign_in",
resource: "auth",
ipAddress,
userAgent,
}),
signOut: (userId: string, email: string, ipAddress?: string) =>
log({
userId,
userEmail: email,
action: "sign_out",
resource: "auth",
ipAddress,
}),
signInFailed: (email: string, reason: string, ipAddress?: string) =>
log({
userEmail: email,
action: "sign_in_failed",
resource: "auth",
details: { reason },
ipAddress,
}),
// User management events
userCreated: (actorId: string, actorEmail: string, targetUserId: string, targetEmail: string) =>
log({
userId: actorId,
userEmail: actorEmail,
action: "create",
resource: "user",
resourceId: targetUserId,
details: { targetEmail },
}),
userUpdated: (actorId: string, actorEmail: string, targetUserId: string, changes: Record<string, unknown>) =>
log({
userId: actorId,
userEmail: actorEmail,
action: "update",
resource: "user",
resourceId: targetUserId,
details: { changes },
}),
userDeleted: (actorId: string, actorEmail: string, targetUserId: string, targetEmail: string) =>
log({
userId: actorId,
userEmail: actorEmail,
action: "delete",
resource: "user",
resourceId: targetUserId,
details: { targetEmail },
}),
userBanned: (actorId: string, actorEmail: string, targetUserId: string, reason?: string) =>
log({
userId: actorId,
userEmail: actorEmail,
action: "ban",
resource: "user",
resourceId: targetUserId,
details: { reason },
}),
userUnbanned: (actorId: string, actorEmail: string, targetUserId: string) =>
log({
userId: actorId,
userEmail: actorEmail,
action: "unban",
resource: "user",
resourceId: targetUserId,
}),
userRoleChanged: (actorId: string, actorEmail: string, targetUserId: string, oldRole: string, newRole: string) =>
log({
userId: actorId,
userEmail: actorEmail,
action: "role_change",
resource: "user",
resourceId: targetUserId,
details: { oldRole, newRole },
}),
// Role management events
roleCreated: (actorId: string, actorEmail: string, roleId: string, roleName: string) =>
log({
userId: actorId,
userEmail: actorEmail,
action: "create",
resource: "role",
resourceId: roleId,
details: { roleName },
}),
roleUpdated: (actorId: string, actorEmail: string, roleId: string, changes: Record<string, unknown>) =>
log({
userId: actorId,
userEmail: actorEmail,
action: "update",
resource: "role",
resourceId: roleId,
details: { changes },
}),
roleDeleted: (actorId: string, actorEmail: string, roleId: string, roleName: string) =>
log({
userId: actorId,
userEmail: actorEmail,
action: "delete",
resource: "role",
resourceId: roleId,
details: { roleName },
}),
// Settings events
settingsUpdated: (actorId: string, actorEmail: string, setting: string, oldValue: unknown, newValue: unknown) =>
log({
userId: actorId,
userEmail: actorEmail,
action: "update",
resource: "settings",
resourceId: setting,
details: { oldValue, newValue },
}),
};
/**
* Purge old audit logs based on retention policy
*/
export async function purgeOldLogs(retentionDays?: number): Promise<number> {
const days = retentionDays || parseInt(process.env.AUDIT_LOG_RETENTION_DAYS || "90");
try {
const result = await pool.query(
`DELETE FROM "audit_log" WHERE timestamp < NOW() - INTERVAL '${days} days'`
);
return result.rowCount || 0;
} catch (error) {
console.error("Failed to purge audit logs:", error);
return 0;
}
}

165
lib/services/email.ts Normal file
View file

@ -0,0 +1,165 @@
import * as nodemailer from "nodemailer";
// SMTP configuration from environment variables
const smtpConfig = {
host: process.env.SMTP_HOST || "smtp.example.com",
port: parseInt(process.env.SMTP_PORT || "587"),
secure: process.env.SMTP_PORT === "465", // true for 465, false for other ports
auth: {
user: process.env.SMTP_USER || "",
pass: process.env.SMTP_PASSWORD || "",
},
};
const fromAddress = process.env.SMTP_FROM || "noreply@example.com";
// Create reusable transporter
let transporter: nodemailer.Transporter | null = null;
function getTransporter(): nodemailer.Transporter {
if (!transporter) {
transporter = nodemailer.createTransport(smtpConfig);
}
return transporter;
}
// Email templates
interface MagicLinkEmailParams {
email: string;
url: string;
token: string;
}
export async function sendMagicLinkEmail({
email,
url,
}: MagicLinkEmailParams): Promise<void> {
const transport = getTransporter();
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in to Pulse</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
</div>
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
<h2 style="color: #333; margin-top: 0;">Sign in to your account</h2>
<p>Click the button below to sign in to Pulse. This link will expire in 5 minutes.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${url}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
Sign in to Pulse
</a>
</div>
<p style="color: #666; font-size: 14px;">If you didn't request this email, you can safely ignore it.</p>
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
<p style="color: #999; font-size: 12px;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="${url}" style="color: #667eea; word-break: break-all;">${url}</a>
</p>
</div>
</body>
</html>
`;
const text = `
Sign in to Pulse
Click the link below to sign in to your account. This link will expire in 5 minutes.
${url}
If you didn't request this email, you can safely ignore it.
`;
await transport.sendMail({
from: fromAddress,
to: email,
subject: "Sign in to Pulse",
text,
html,
});
}
interface InvitationEmailParams {
email: string;
inviterName: string;
url: string;
}
export async function sendInvitationEmail({
email,
inviterName,
url,
}: InvitationEmailParams): Promise<void> {
const transport = getTransporter();
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>You're invited to Pulse</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
</div>
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
<h2 style="color: #333; margin-top: 0;">You're invited!</h2>
<p><strong>${inviterName}</strong> has invited you to join Pulse.</p>
<p>Click the button below to accept the invitation and set up your account.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${url}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
Accept Invitation
</a>
</div>
<p style="color: #666; font-size: 14px;">If you weren't expecting this invitation, you can safely ignore this email.</p>
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
<p style="color: #999; font-size: 12px;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="${url}" style="color: #667eea; word-break: break-all;">${url}</a>
</p>
</div>
</body>
</html>
`;
const text = `
You're invited to Pulse!
${inviterName} has invited you to join Pulse.
Click the link below to accept the invitation and set up your account:
${url}
If you weren't expecting this invitation, you can safely ignore this email.
`;
await transport.sendMail({
from: fromAddress,
to: email,
subject: "You're invited to Pulse",
text,
html,
});
}
// Verify SMTP connection
export async function verifyEmailConnection(): Promise<boolean> {
try {
const transport = getTransporter();
await transport.verify();
return true;
} catch (error) {
console.error("SMTP connection verification failed:", error);
return false;
}
}

View file

@ -0,0 +1,208 @@
interface SalesBldrConfig {
apiUrl: string;
apiKey: string;
}
interface QuoteItem {
id: string;
category?: object;
cost?: number;
mpn?: string;
markup?: number;
deliveryDate?: string;
discount?: number;
unit?: string;
term?: string;
name: string;
price?: number;
quantity: number;
shortDescription?: string;
}
interface Quote {
id: string;
title: string;
number: string;
status: string;
createdAt: string;
sentAt?: string;
expiresAt?: string;
approvedAt?: string;
company?: {
id: string;
name: string;
externalIdentifier?: string;
};
contact?: object;
owner?: object;
items?: QuoteItem[];
opportunity?: {
id: string;
name: string;
description?: string;
};
}
interface QuoteSearchResponse {
results: Quote[];
total: number;
filters?: any[];
sortOptions?: any[];
}
export class SalesBldrClient {
private config: SalesBldrConfig;
constructor(config?: SalesBldrConfig) {
this.config = config || {
apiUrl: process.env.SALESBLDR_API_URL || '',
apiKey: process.env.SALESBLDR_API_KEY || ''
};
}
private ensureConfig() {
if (!this.config.apiUrl || !this.config.apiKey) {
throw new Error('SalesBldr API URL and API Key are required');
}
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
this.ensureConfig();
const url = `${this.config.apiUrl}${endpoint}`;
const headers: HeadersInit = {
'api-key': this.config.apiKey,
'Content-Type': 'application/json',
...options.headers,
};
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`SalesBldr API error: ${response.status} ${response.statusText} - ${errorText}`
);
}
return response.json();
}
async getQuotes(params?: {
sort?: string;
filters?: string;
query?: string;
size?: number;
from?: number;
}): Promise<QuoteSearchResponse> {
const queryParams = new URLSearchParams();
if (params?.sort) queryParams.append('sort', params.sort);
if (params?.filters) queryParams.append('filters', params.filters);
if (params?.query) queryParams.append('query', params.query);
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
const endpoint = `/public-api/quote${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return this.request<QuoteSearchResponse>(endpoint);
}
async getOpenQuotes(size: number = 10): Promise<QuoteSearchResponse> {
return this.getQuotes({
filters: 'status:sent|draft',
sort: '-createdAt',
size
});
}
async getApprovedQuotes(size: number = 10): Promise<Quote[]> {
const endpoint = `/public-api/quote/approved`;
return this.request<Quote[]>(endpoint);
}
async getOpportunities(params?: {
sort?: string;
filters?: string;
query?: string;
size?: number;
from?: number;
}): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.sort) queryParams.append('sort', params.sort);
if (params?.filters) queryParams.append('filters', params.filters);
if (params?.query) queryParams.append('query', params.query);
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
const endpoint = `/public-api/opportunity${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return this.request(endpoint);
}
async getProducts(params?: {
includeBundles?: boolean;
sort?: string;
filters?: string;
query?: string;
size?: number;
from?: number;
}): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.includeBundles !== undefined) queryParams.append('includeBundles', params.includeBundles.toString());
if (params?.sort) queryParams.append('sort', params.sort);
if (params?.filters) queryParams.append('filters', params.filters);
if (params?.query) queryParams.append('query', params.query);
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
const endpoint = `/public-api/product${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return this.request(endpoint);
}
async getCompanies(params?: {
sort?: string;
filters?: string;
query?: string;
size?: number;
from?: number;
}): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.sort) queryParams.append('sort', params.sort);
if (params?.filters) queryParams.append('filters', params.filters);
if (params?.query) queryParams.append('query', params.query);
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
const endpoint = `/public-api/company${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return this.request(endpoint);
}
async getContacts(params?: {
sort?: string;
filters?: string;
query?: string;
size?: number;
from?: number;
}): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.sort) queryParams.append('sort', params.sort);
if (params?.filters) queryParams.append('filters', params.filters);
if (params?.query) queryParams.append('query', params.query);
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
const endpoint = `/public-api/contact${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return this.request(endpoint);
}
}
export const salesBldrClient = new SalesBldrClient();