Files
claudetools/projects/msp-tools/quote-wizard/php-api/api/services/quote_service.php
Mike Swanson fa15b03180 sync: Auto-sync from ACG-M-L5090 at 2026-03-10 19:11:00
Synced files:
- Quote wizard frontend (all components, hooks, types, config)
- API updates (config, models, routers, schemas, services)
- Client work (bg-builders, gurushow)
- Scripts (BGB Lesley termination, CIPP, Datto, migration)
- Temp files (Bardach contacts, VWP investigation, misc)
- Credentials and session logs
- Email service, PHP API, session logs

Machine: ACG-M-L5090
Timestamp: 2026-03-10 19:11:00

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:59:08 -07:00

1061 lines
35 KiB
PHP

<?php
/**
* Quote business logic service.
*
* Handles all database operations for quotes: create, read, update, submit,
* item management, totals calculation, activity logging, and admin functions.
*
* All functions accept a PDO instance and return associative arrays.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../helpers.php';
require_once __DIR__ . '/../db.php';
// --------------------------------------------------------------------------
// Constants
// --------------------------------------------------------------------------
define('VALID_STATUSES', [
'draft', 'submitted', 'viewed', 'followed_up', 'converted', 'expired', 'archived'
]);
define('VALID_CATEGORIES', [
'gps_monitoring', 'support_plan', 'voip', 'web_hosting', 'email',
'hardware', 'addon', 'backup', 'security', 'other'
]);
define('VALID_BILLING_FREQUENCIES', ['monthly', 'yearly', 'one_time']);
define('SUBMITTED_STATUSES', ['submitted', 'viewed', 'followed_up', 'converted']);
// --------------------------------------------------------------------------
// Table name helpers
// --------------------------------------------------------------------------
function tbl_quotes(): string { return DB_TABLE_PREFIX . 'quotes'; }
function tbl_items(): string { return DB_TABLE_PREFIX . 'quote_items'; }
function tbl_activity(): string { return DB_TABLE_PREFIX . 'quote_activity'; }
function tbl_notif(): string { return DB_TABLE_PREFIX . 'quote_notifications'; }
// --------------------------------------------------------------------------
// Activity logging
// --------------------------------------------------------------------------
/**
* Log an activity record for a quote.
*
* @param PDO $db Database connection.
* @param string $quote_id UUID of the quote.
* @param string $action Action name (created, updated, submitted, etc.).
* @param string|null $details Freeform details (stored as JSON wrapper for CHECK compat).
* @param string|null $step_name Wizard step name.
* @param string|null $ip Client IP address.
*/
function log_activity(
PDO $db,
string $quote_id,
string $action,
?string $details = null,
?string $step_name = null,
?string $ip = null
): void {
$id = generate_uuid();
$details_json = $details !== null ? json_encode(['message' => $details]) : null;
$sql = sprintf(
'INSERT INTO %s (id, quote_id, action, step_name, details, ip_address, created_at)
VALUES (:id, :quote_id, :action, :step_name, :details, :ip, :created_at)',
tbl_activity()
);
$stmt = $db->prepare($sql);
$stmt->execute([
':id' => $id,
':quote_id' => $quote_id,
':action' => $action,
':step_name' => $step_name,
':details' => $details_json,
':ip' => $ip,
':created_at' => utc_now(),
]);
}
// --------------------------------------------------------------------------
// Totals calculation
// --------------------------------------------------------------------------
/**
* Calculate monthly and setup totals from a list of item rows.
*
* Monthly calculation:
* - monthly items: unit_price * quantity
* - yearly items: unit_price * quantity / 12
* - one_time items: 0
*
* Setup total: sum of all setup_price values.
*
* @param array $items Array of item associative arrays.
* @return array{monthly: string, setup: string} Formatted totals with 2 decimal places.
*/
function calculate_totals(array $items): array
{
$monthly = '0';
$setup = '0';
foreach ($items as $item) {
$unit_price = $item['unit_price'] ?? '0';
$qty = (int)($item['quantity'] ?? 1);
$setup_price = $item['setup_price'] ?? '0';
$freq = $item['billing_frequency'] ?? 'monthly';
$line_total = bcmul($unit_price, (string)$qty, 4);
if ($freq === 'monthly') {
$monthly = bcadd($monthly, $line_total, 4);
} elseif ($freq === 'yearly') {
$monthly_portion = bcdiv($line_total, '12', 4);
$monthly = bcadd($monthly, $monthly_portion, 4);
}
// one_time items contribute 0 to monthly
$setup = bcadd($setup, $setup_price, 4);
}
return [
'monthly' => number_format((float)$monthly, 2, '.', ''),
'setup' => number_format((float)$setup, 2, '.', ''),
];
}
/**
* Recalculate and persist totals for a quote by summing its items.
*
* @param PDO $db Database connection.
* @param string $quote_id UUID of the quote.
*/
function recalculate_totals(PDO $db, string $quote_id): void
{
$items = fetch_items_for_quote($db, $quote_id);
$totals = calculate_totals($items);
$sql = sprintf(
'UPDATE %s SET monthly_total = :monthly, setup_total = :setup, updated_at = :now WHERE id = :id',
tbl_quotes()
);
$stmt = $db->prepare($sql);
$stmt->execute([
':monthly' => $totals['monthly'],
':setup' => $totals['setup'],
':now' => utc_now(),
':id' => $quote_id,
]);
}
// --------------------------------------------------------------------------
// Item helpers
// --------------------------------------------------------------------------
/**
* Fetch all items for a given quote.
*
* @param PDO $db Database connection.
* @param string $quote_id UUID of the quote.
* @return array List of item rows.
*/
function fetch_items_for_quote(PDO $db, string $quote_id): array
{
$sql = sprintf('SELECT * FROM %s WHERE quote_id = :qid ORDER BY created_at ASC', tbl_items());
$stmt = $db->prepare($sql);
$stmt->execute([':qid' => $quote_id]);
return $stmt->fetchAll();
}
/**
* Insert a single item row.
*
* @param PDO $db Database connection.
* @param string $quote_id UUID of the parent quote.
* @param array $item Item data with keys matching the schema.
* @return string The generated item UUID.
*/
function insert_item(PDO $db, string $quote_id, array $item): string
{
$id = generate_uuid();
$sql = sprintf(
'INSERT INTO %s
(id, quote_id, category, product_code, product_name, description,
quantity, unit_price, setup_price, billing_frequency, tier, is_recommended, created_at)
VALUES
(:id, :qid, :cat, :code, :name, :desc, :qty, :price, :setup, :freq, :tier, :rec, :created_at)',
tbl_items()
);
$stmt = $db->prepare($sql);
$stmt->execute([
':id' => $id,
':qid' => $quote_id,
':cat' => $item['category'] ?? 'other',
':code' => $item['product_code'] ?? '',
':name' => $item['product_name'] ?? '',
':desc' => $item['description'] ?? null,
':qty' => (int)($item['quantity'] ?? 1),
':price' => $item['unit_price'] ?? '0.00',
':setup' => $item['setup_price'] ?? '0.00',
':freq' => $item['billing_frequency'] ?? 'monthly',
':tier' => $item['tier'] ?? null,
':rec' => (int)($item['is_recommended'] ?? 0),
':created_at' => utc_now(),
]);
return $id;
}
/**
* Delete all items for a quote.
*
* @param PDO $db Database connection.
* @param string $quote_id UUID of the quote.
*/
function delete_all_items(PDO $db, string $quote_id): void
{
$sql = sprintf('DELETE FROM %s WHERE quote_id = :qid', tbl_items());
$stmt = $db->prepare($sql);
$stmt->execute([':qid' => $quote_id]);
}
// --------------------------------------------------------------------------
// Quote CRUD
// --------------------------------------------------------------------------
/**
* Create a new quote draft.
*
* Generates a UUID and access token, inserts the quote row, optionally
* inserts initial items, calculates totals, and logs a "created" activity.
*
* @param PDO $db Database connection.
* @param array $data Request body data (employee_count, items).
* @param string|null $ip Client IP.
* @param string|null $ua User-Agent.
* @return array The created quote row (without items).
*/
function create_quote(PDO $db, array $data, ?string $ip = null, ?string $ua = null): array
{
$id = generate_uuid();
$token = generate_access_token();
$now = utc_now();
$expires = gmdate('Y-m-d H:i:s', strtotime("+".QUOTE_DRAFT_EXPIRY_DAYS." days"));
$employee_count = isset($data['employee_count']) ? (int)$data['employee_count'] : null;
$sql = sprintf(
'INSERT INTO %s
(id, access_token, status, employee_count, ip_address, user_agent,
monthly_total, setup_total, expires_at, created_at, updated_at)
VALUES
(:id, :token, :status, :emp, :ip, :ua, :mt, :st, :exp, :now, :now2)',
tbl_quotes()
);
try {
$db->beginTransaction();
$stmt = $db->prepare($sql);
$stmt->execute([
':id' => $id,
':token' => $token,
':status' => 'draft',
':emp' => $employee_count,
':ip' => $ip,
':ua' => $ua,
':mt' => '0.00',
':st' => '0.00',
':exp' => $expires,
':now' => $now,
':now2' => $now,
]);
// Insert initial items if provided
if (!empty($data['items']) && is_array($data['items'])) {
foreach ($data['items'] as $item) {
insert_item($db, $id, $item);
}
recalculate_totals($db, $id);
}
// Log activity
log_activity(
$db, $id, 'created',
"Quote draft created, employee_count={$employee_count}",
null, $ip
);
$db->commit();
} catch (PDOException $e) {
$db->rollBack();
// If token collision, retry once
if (strpos($e->getMessage(), 'access_token') !== false) {
app_log('WARNING', 'Access token collision, retrying create_quote');
return create_quote($db, $data, $ip, $ua);
}
app_log('ERROR', 'Failed to create quote: ' . $e->getMessage());
error_response('Failed to create quote: ' . $e->getMessage(), 500);
}
return fetch_quote_row($db, $id);
}
/**
* Fetch a raw quote row by ID.
*
* @param PDO $db Database connection.
* @param string $id Quote UUID.
* @return array|null Quote row or null.
*/
function fetch_quote_row(PDO $db, string $id): ?array
{
$sql = sprintf('SELECT * FROM %s WHERE id = :id', tbl_quotes());
$stmt = $db->prepare($sql);
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
return $row ?: null;
}
/**
* Fetch a quote by access token, with expiration check.
*
* If the quote is a draft and expired, its status is updated to "expired".
*
* @param PDO $db Database connection.
* @param string $token Access token.
* @return array Quote row.
*/
function get_quote_by_token(PDO $db, string $token): array
{
$sql = sprintf('SELECT * FROM %s WHERE access_token = :token', tbl_quotes());
$stmt = $db->prepare($sql);
$stmt->execute([':token' => $token]);
$quote = $stmt->fetch();
if (!$quote) {
error_response('Quote not found', 404);
}
// Check expiration for draft quotes
if (
$quote['status'] === 'draft'
&& $quote['expires_at'] !== null
&& strtotime($quote['expires_at']) < time()
) {
$upd = sprintf('UPDATE %s SET status = :s, updated_at = :now WHERE id = :id', tbl_quotes());
$stmt2 = $db->prepare($upd);
$stmt2->execute([':s' => 'expired', ':now' => utc_now(), ':id' => $quote['id']]);
$quote['status'] = 'expired';
}
return $quote;
}
/**
* Fetch a quote by its UUID (admin access).
*
* @param PDO $db Database connection.
* @param string $id Quote UUID.
* @return array Quote row.
*/
function get_quote_by_id(PDO $db, string $id): array
{
$quote = fetch_quote_row($db, $id);
if (!$quote) {
error_response("Quote with ID {$id} not found", 404);
}
return $quote;
}
/**
* Build the full public quote response (quote + items, with computed fields).
*
* @param PDO $db Database connection.
* @param array $quote Raw quote row.
* @return array Response-ready associative array.
*/
function build_quote_response(PDO $db, array $quote): array
{
$items_raw = fetch_items_for_quote($db, $quote['id']);
$items = array_map('format_item_response', $items_raw);
return [
'id' => $quote['id'],
'access_token' => $quote['access_token'],
'status' => $quote['status'],
'company_name' => $quote['company_name'],
'contact_name' => $quote['contact_name'],
'contact_email' => $quote['contact_email'],
'contact_phone' => $quote['contact_phone'],
'employee_count' => $quote['employee_count'] !== null ? (int)$quote['employee_count'] : null,
'monthly_total' => number_format((float)$quote['monthly_total'], 2, '.', ''),
'setup_total' => number_format((float)$quote['setup_total'], 2, '.', ''),
'expires_at' => format_datetime($quote['expires_at']),
'submitted_at' => format_datetime($quote['submitted_at']),
'created_at' => format_datetime($quote['created_at']),
'updated_at' => format_datetime($quote['updated_at']),
'items' => $items,
];
}
/**
* Build the full admin quote response (quote + items + activities + notifications).
*
* @param PDO $db Database connection.
* @param array $quote Raw quote row.
* @return array Response-ready associative array.
*/
function build_admin_quote_response(PDO $db, array $quote): array
{
$base = build_quote_response($db, $quote);
// Add admin-only fields
$base['ip_address'] = $quote['ip_address'];
$base['user_agent'] = $quote['user_agent'];
// Fetch activities
$sql_act = sprintf(
'SELECT * FROM %s WHERE quote_id = :qid ORDER BY created_at DESC',
tbl_activity()
);
$stmt = $db->prepare($sql_act);
$stmt->execute([':qid' => $quote['id']]);
$activities_raw = $stmt->fetchAll();
$base['activities'] = array_map(function ($a) {
return [
'id' => $a['id'],
'quote_id' => $a['quote_id'],
'action' => $a['action'],
'step_name' => $a['step_name'],
'details' => $a['details'],
'ip_address' => $a['ip_address'],
'created_at' => format_datetime($a['created_at']),
];
}, $activities_raw);
// Fetch notifications
$sql_notif = sprintf(
'SELECT * FROM %s WHERE quote_id = :qid ORDER BY created_at DESC',
tbl_notif()
);
$stmt2 = $db->prepare($sql_notif);
$stmt2->execute([':qid' => $quote['id']]);
$notifs_raw = $stmt2->fetchAll();
$base['notifications'] = array_map(function ($n) {
return [
'id' => $n['id'],
'quote_id' => $n['quote_id'],
'notification_type' => $n['notification_type'],
'recipient' => $n['recipient'],
'subject' => $n['subject'],
'status' => $n['status'],
'attempts' => (int)$n['attempts'],
'last_attempt_at' => format_datetime($n['last_attempt_at']),
'sent_at' => format_datetime($n['sent_at']),
'error_message' => $n['error_message'],
'created_at' => format_datetime($n['created_at']),
];
}, $notifs_raw);
return $base;
}
/**
* Format a raw item row into the API response shape with computed fields.
*
* @param array $item Raw item row from database.
* @return array Formatted item response.
*/
function format_item_response(array $item): array
{
$unit_price = $item['unit_price'] ?? '0.00';
$qty = (int)($item['quantity'] ?? 1);
$freq = $item['billing_frequency'] ?? 'monthly';
$setup = $item['setup_price'] ?? '0.00';
$line_total = bcmul($unit_price, (string)$qty, 2);
if ($freq === 'monthly') {
$monthly_amount = $line_total;
} elseif ($freq === 'yearly') {
$monthly_amount = bcdiv($line_total, '12', 2);
} else {
$monthly_amount = '0.00';
}
return [
'id' => $item['id'],
'quote_id' => $item['quote_id'],
'category' => $item['category'],
'product_code' => $item['product_code'],
'product_name' => $item['product_name'],
'description' => $item['description'],
'quantity' => $qty,
'unit_price' => number_format((float)$unit_price, 2, '.', ''),
'setup_price' => number_format((float)$setup, 2, '.', ''),
'billing_frequency' => $freq,
'tier' => $item['tier'],
'is_recommended' => (bool)$item['is_recommended'],
'line_total' => number_format((float)$line_total, 2, '.', ''),
'monthly_amount' => number_format((float)$monthly_amount, 2, '.', ''),
'created_at' => format_datetime($item['created_at']),
];
}
// --------------------------------------------------------------------------
// Update quote
// --------------------------------------------------------------------------
/**
* Update a draft quote's fields and optionally replace all items.
*
* Only quotes with status "draft" can be updated. If items are provided
* in the data, all existing items are deleted and replaced.
*
* @param PDO $db Database connection.
* @param string $token Access token.
* @param array $data Update data.
* @param string|null $ip Client IP.
* @return array Updated quote row.
*/
function update_quote(PDO $db, string $token, array $data, ?string $ip = null): array
{
$quote = get_quote_by_token($db, $token);
if ($quote['status'] !== 'draft') {
error_response(
"Cannot update quote with status '{$quote['status']}'. Only drafts can be modified.",
400
);
}
try {
$db->beginTransaction();
$changes = [];
$updatable_fields = ['company_name', 'contact_name', 'contact_email', 'contact_phone', 'employee_count'];
$sets = [];
$params = [':id' => $quote['id']];
foreach ($updatable_fields as $field) {
if (array_key_exists($field, $data)) {
$old = $quote[$field];
$new = $data[$field];
if ($old !== $new) {
$sets[] = "{$field} = :{$field}";
$params[":{$field}"] = $new;
$changes[] = "{$field}: {$old} -> {$new}";
}
}
}
if (!empty($sets)) {
$sets[] = 'updated_at = :now';
$params[':now'] = utc_now();
$sql = sprintf('UPDATE %s SET %s WHERE id = :id', tbl_quotes(), implode(', ', $sets));
$stmt = $db->prepare($sql);
$stmt->execute($params);
}
// Replace items if provided
if (array_key_exists('items', $data) && $data['items'] !== null) {
delete_all_items($db, $quote['id']);
$item_count = 0;
if (is_array($data['items'])) {
foreach ($data['items'] as $item) {
insert_item($db, $quote['id'], $item);
$item_count++;
}
}
$changes[] = "items: replaced with {$item_count} items";
}
// Recalculate totals
recalculate_totals($db, $quote['id']);
// Log activity
if (!empty($changes)) {
log_activity(
$db, $quote['id'], 'updated',
'Quote updated: ' . implode(', ', $changes),
null, $ip
);
}
$db->commit();
} catch (PDOException $e) {
$db->rollBack();
app_log('ERROR', 'Failed to update quote: ' . $e->getMessage());
error_response('Failed to update quote: ' . $e->getMessage(), 500);
}
return get_quote_by_token($db, $token);
}
// --------------------------------------------------------------------------
// Add / remove items
// --------------------------------------------------------------------------
/**
* Add a single item to a draft quote.
*
* @param PDO $db Database connection.
* @param string $token Access token.
* @param array $item Item data.
* @param string|null $ip Client IP.
* @return array Updated quote row.
*/
function add_item(PDO $db, string $token, array $item, ?string $ip = null): array
{
$quote = get_quote_by_token($db, $token);
if ($quote['status'] !== 'draft') {
error_response('Cannot add items to a non-draft quote', 400);
}
try {
$db->beginTransaction();
insert_item($db, $quote['id'], $item);
recalculate_totals($db, $quote['id']);
log_activity(
$db, $quote['id'], 'item_added',
'Added item: ' . ($item['product_name'] ?? 'unknown'),
null, $ip
);
$db->commit();
} catch (PDOException $e) {
$db->rollBack();
app_log('ERROR', 'Failed to add item: ' . $e->getMessage());
error_response('Failed to add item: ' . $e->getMessage(), 500);
}
return get_quote_by_token($db, $token);
}
/**
* Remove a single item from a draft quote.
*
* @param PDO $db Database connection.
* @param string $token Access token.
* @param string $item_id UUID of the item to remove.
* @param string|null $ip Client IP.
* @return array Updated quote row.
*/
function remove_item(PDO $db, string $token, string $item_id, ?string $ip = null): array
{
$quote = get_quote_by_token($db, $token);
if ($quote['status'] !== 'draft') {
error_response('Cannot remove items from a non-draft quote', 400);
}
// Find the item
$sql = sprintf(
'SELECT * FROM %s WHERE id = :iid AND quote_id = :qid',
tbl_items()
);
$stmt = $db->prepare($sql);
$stmt->execute([':iid' => $item_id, ':qid' => $quote['id']]);
$item = $stmt->fetch();
if (!$item) {
error_response("Item with ID {$item_id} not found in this quote", 404);
}
try {
$db->beginTransaction();
$del = sprintf('DELETE FROM %s WHERE id = :iid', tbl_items());
$stmt2 = $db->prepare($del);
$stmt2->execute([':iid' => $item_id]);
recalculate_totals($db, $quote['id']);
log_activity(
$db, $quote['id'], 'item_removed',
'Removed item: ' . $item['product_name'],
null, $ip
);
$db->commit();
} catch (PDOException $e) {
$db->rollBack();
app_log('ERROR', 'Failed to remove item: ' . $e->getMessage());
error_response('Failed to remove item: ' . $e->getMessage(), 500);
}
return get_quote_by_token($db, $token);
}
// --------------------------------------------------------------------------
// Submit quote
// --------------------------------------------------------------------------
/**
* Submit a draft quote with contact information.
*
* Validates the quote is a draft and has items, updates contact fields,
* sets status to "submitted", extends expiration to 90 days, and creates
* a notification record.
*
* @param PDO $db Database connection.
* @param string $token Access token.
* @param array $data Submission data (company_name, contact_name, contact_email, etc.).
* @param string|null $ip Client IP.
* @return array The submitted quote row.
*/
function submit_quote(PDO $db, string $token, array $data, ?string $ip = null): array
{
$quote = get_quote_by_token($db, $token);
if ($quote['status'] !== 'draft') {
error_response(
"Cannot submit quote with status '{$quote['status']}'. Only drafts can be submitted.",
400
);
}
// Must have at least one item
$items = fetch_items_for_quote($db, $quote['id']);
if (empty($items)) {
error_response(
'Cannot submit quote without any items. Please add at least one service.',
400
);
}
$now = utc_now();
$expires = gmdate('Y-m-d H:i:s', strtotime("+" . QUOTE_SUBMITTED_EXPIRY_DAYS . " days"));
try {
$db->beginTransaction();
// Update contact info and status
$sql = sprintf(
'UPDATE %s SET
company_name = :company,
contact_name = :name,
contact_email = :email,
contact_phone = :phone,
status = :status,
submitted_at = :submitted,
expires_at = :expires,
updated_at = :now
WHERE id = :id',
tbl_quotes()
);
$stmt = $db->prepare($sql);
$stmt->execute([
':company' => trim($data['company_name'] ?? ''),
':name' => trim($data['contact_name'] ?? ''),
':email' => $data['contact_email'] ?? '',
':phone' => $data['contact_phone'] ?? null,
':status' => 'submitted',
':submitted' => $now,
':expires' => $expires,
':now' => $now,
':id' => $quote['id'],
]);
// Refresh the quote row to get updated totals
$refreshed = fetch_quote_row($db, $quote['id']);
// Log activity
$contact_name = trim($data['contact_name'] ?? '');
$contact_email = $data['contact_email'] ?? '';
$company_name = trim($data['company_name'] ?? '');
log_activity(
$db, $quote['id'], 'submitted',
"Quote submitted by {$contact_name} ({$contact_email}), company={$company_name}, monthly=\${$refreshed['monthly_total']}, setup=\${$refreshed['setup_total']}",
null, $ip
);
// Create notification record
$notif_id = generate_uuid();
$sql_notif = sprintf(
'INSERT INTO %s
(id, quote_id, notification_type, recipient, subject, body, status, created_at)
VALUES
(:id, :qid, :type, :recipient, :subject, :body, :status, :created_at)',
tbl_notif()
);
$stmt_n = $db->prepare($sql_notif);
$stmt_n->execute([
':id' => $notif_id,
':qid' => $quote['id'],
':type' => 'email',
':recipient' => ADMIN_NOTIFICATION_EMAIL,
':subject' => "New Quote Submission: {$company_name}",
':body' => "Quote submitted by {$contact_name}. Monthly: \${$refreshed['monthly_total']}",
':status' => 'pending',
':created_at' => $now,
]);
$db->commit();
} catch (PDOException $e) {
$db->rollBack();
app_log('ERROR', 'Failed to submit quote: ' . $e->getMessage());
error_response('Failed to submit quote: ' . $e->getMessage(), 500);
}
return get_quote_by_token($db, $token);
}
/**
* Update the most recent notification status for a quote.
*
* @param PDO $db Database connection.
* @param string $quote_id Quote UUID.
* @param string $status New status (sent, failed).
* @param string|null $error Error message if failed.
*/
function update_notification_status(PDO $db, string $quote_id, string $status, ?string $error = null): void
{
$now = utc_now();
$sql = sprintf(
'UPDATE %s SET
status = :status,
attempts = attempts + 1,
last_attempt_at = :now,
sent_at = :sent,
error_message = :err
WHERE quote_id = :qid
ORDER BY created_at DESC
LIMIT 1',
tbl_notif()
);
$stmt = $db->prepare($sql);
$stmt->execute([
':status' => $status,
':now' => $now,
':sent' => $status === 'sent' ? $now : null,
':err' => $error,
':qid' => $quote_id,
]);
}
// --------------------------------------------------------------------------
// Admin functions
// --------------------------------------------------------------------------
/**
* List quotes with pagination and optional filters.
*
* @param PDO $db Database connection.
* @param int $skip Offset.
* @param int $limit Max results.
* @param string|null $status Filter by status.
* @param string|null $search Search in company_name, contact_name, contact_email.
* @return array{quotes: array, total: int}
*/
function list_quotes(PDO $db, int $skip = 0, int $limit = 100, ?string $status = null, ?string $search = null): array
{
$where = [];
$params = [];
if ($status !== null && $status !== '') {
$where[] = 'q.status = :status';
$params[':status'] = $status;
}
if ($search !== null && $search !== '') {
$where[] = '(q.company_name LIKE :search OR q.contact_name LIKE :search2 OR q.contact_email LIKE :search3)';
$term = '%' . $search . '%';
$params[':search'] = $term;
$params[':search2'] = $term;
$params[':search3'] = $term;
}
$where_sql = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
// Get total count
$count_sql = sprintf('SELECT COUNT(*) FROM %s q %s', tbl_quotes(), $where_sql);
$stmt = $db->prepare($count_sql);
$stmt->execute($params);
$total = (int)$stmt->fetchColumn();
// Get paginated results with item count
$list_sql = sprintf(
'SELECT q.*, (SELECT COUNT(*) FROM %s qi WHERE qi.quote_id = q.id) AS item_count
FROM %s q
%s
ORDER BY q.created_at DESC
LIMIT :lim OFFSET :off',
tbl_items(), tbl_quotes(), $where_sql
);
$stmt2 = $db->prepare($list_sql);
foreach ($params as $k => $v) {
$stmt2->bindValue($k, $v);
}
$stmt2->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt2->bindValue(':off', $skip, PDO::PARAM_INT);
$stmt2->execute();
$rows = $stmt2->fetchAll();
$quotes = array_map(function ($row) {
return [
'id' => $row['id'],
'access_token' => $row['access_token'],
'status' => $row['status'],
'company_name' => $row['company_name'],
'contact_name' => $row['contact_name'],
'contact_email' => $row['contact_email'],
'employee_count' => $row['employee_count'] !== null ? (int)$row['employee_count'] : null,
'monthly_total' => number_format((float)$row['monthly_total'], 2, '.', ''),
'setup_total' => number_format((float)$row['setup_total'], 2, '.', ''),
'item_count' => (int)$row['item_count'],
'submitted_at' => format_datetime($row['submitted_at']),
'created_at' => format_datetime($row['created_at']),
];
}, $rows);
return ['quotes' => $quotes, 'total' => $total];
}
/**
* Get dashboard statistics for quotes.
*
* @param PDO $db Database connection.
* @return array Statistics matching QuoteStatsResponse schema.
*/
function get_stats(PDO $db): array
{
$tbl = tbl_quotes();
// Total quotes
$stmt = $db->query("SELECT COUNT(*) FROM {$tbl}");
$total_quotes = (int)$stmt->fetchColumn();
// Quotes by status
$stmt2 = $db->query("SELECT status, COUNT(*) as cnt FROM {$tbl} GROUP BY status");
$status_rows = $stmt2->fetchAll();
$quotes_by_status = [];
foreach ($status_rows as $r) {
$quotes_by_status[$r['status']] = (int)$r['cnt'];
}
// Total values for submitted quotes
$placeholders = implode(',', array_fill(0, count(SUBMITTED_STATUSES), '?'));
$val_sql = "SELECT COALESCE(SUM(monthly_total), 0), COALESCE(SUM(setup_total), 0) FROM {$tbl} WHERE status IN ({$placeholders})";
$stmt3 = $db->prepare($val_sql);
$stmt3->execute(SUBMITTED_STATUSES);
$vals = $stmt3->fetch(PDO::FETCH_NUM);
$total_monthly_value = (float)$vals[0];
$total_setup_value = (float)$vals[1];
// Quotes this month
$month_start = gmdate('Y-m-01 00:00:00');
$stmt4 = $db->prepare("SELECT COUNT(*) FROM {$tbl} WHERE created_at >= ?");
$stmt4->execute([$month_start]);
$quotes_this_month = (int)$stmt4->fetchColumn();
// Submitted this month
$stmt5 = $db->prepare("SELECT COUNT(*) FROM {$tbl} WHERE submitted_at >= ? AND submitted_at IS NOT NULL");
$stmt5->execute([$month_start]);
$quotes_submitted_this_month = (int)$stmt5->fetchColumn();
// Calculations
$submitted_count = 0;
foreach (SUBMITTED_STATUSES as $s) {
$submitted_count += ($quotes_by_status[$s] ?? 0);
}
$average_monthly_value = $submitted_count > 0
? round($total_monthly_value / $submitted_count, 2)
: 0.00;
$draft_count = $quotes_by_status['draft'] ?? 0;
$total_started = $draft_count + $submitted_count;
$conversion_rate = $total_started > 0
? round(($submitted_count / $total_started) * 100, 2)
: 0.00;
return [
'total_quotes' => $total_quotes,
'quotes_by_status' => $quotes_by_status,
'total_monthly_value' => number_format($total_monthly_value, 2, '.', ''),
'total_setup_value' => number_format($total_setup_value, 2, '.', ''),
'quotes_this_month' => $quotes_this_month,
'quotes_submitted_this_month' => $quotes_submitted_this_month,
'average_monthly_value' => number_format($average_monthly_value, 2, '.', ''),
'conversion_rate' => number_format($conversion_rate, 2, '.', ''),
];
}
/**
* Admin update of a quote's status and/or expiration.
*
* @param PDO $db Database connection.
* @param string $quote_id Quote UUID.
* @param array $data Update data (status, expires_at).
* @param string $admin_user Admin username for audit trail.
* @return array Updated quote row.
*/
function admin_update_quote(PDO $db, string $quote_id, array $data, string $admin_user = 'admin'): array
{
$quote = get_quote_by_id($db, $quote_id);
$changes = [];
$sets = [];
$params = [':id' => $quote_id];
if (isset($data['status']) && $data['status'] !== $quote['status']) {
if (!in_array($data['status'], VALID_STATUSES, true)) {
error_response("Invalid status: {$data['status']}", 400);
}
$old_status = $quote['status'];
$sets[] = 'status = :status';
$params[':status'] = $data['status'];
$changes[] = "status: {$old_status} -> {$data['status']}";
}
if (isset($data['expires_at'])) {
$sets[] = 'expires_at = :expires';
$params[':expires'] = $data['expires_at'];
$changes[] = "expires_at: {$data['expires_at']}";
}
if (!empty($sets)) {
$sets[] = 'updated_at = :now';
$params[':now'] = utc_now();
$sql = sprintf('UPDATE %s SET %s WHERE id = :id', tbl_quotes(), implode(', ', $sets));
$stmt = $db->prepare($sql);
$stmt->execute($params);
log_activity(
$db, $quote_id, 'admin_update',
"Admin update by {$admin_user}: " . implode(', ', $changes)
);
}
return get_quote_by_id($db, $quote_id);
}