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>
This commit is contained in:
148
projects/msp-tools/quote-wizard/php-api/api/routes/admin.php
Normal file
148
projects/msp-tools/quote-wizard/php-api/api/routes/admin.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin route handlers for quote management.
|
||||
*
|
||||
* All handlers require a valid API key in the Authorization header.
|
||||
* Format: Authorization: Bearer {ADMIN_API_KEY}
|
||||
*/
|
||||
|
||||
// Deny direct access
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
||||
http_response_code(403);
|
||||
exit('Direct access denied.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
require_once __DIR__ . '/../db.php';
|
||||
require_once __DIR__ . '/../services/quote_service.php';
|
||||
require_once __DIR__ . '/../services/syncro_service.php';
|
||||
|
||||
/**
|
||||
* Verify the admin API key from the Authorization header.
|
||||
*
|
||||
* Expects: Authorization: Bearer {api_key}
|
||||
* Terminates with 401 if missing or invalid.
|
||||
*/
|
||||
function check_admin_auth(): void
|
||||
{
|
||||
$header = $_SERVER['HTTP_AUTHORIZATION']
|
||||
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||
?? '';
|
||||
|
||||
// Apache CGI/suPHP may strip Authorization header; check env var fallback
|
||||
if (empty($header) && !empty(getenv('HTTP_AUTHORIZATION'))) {
|
||||
$header = getenv('HTTP_AUTHORIZATION');
|
||||
}
|
||||
|
||||
if (empty($header)) {
|
||||
error_response('Authorization header required', 401);
|
||||
}
|
||||
|
||||
// Extract bearer token
|
||||
if (strpos($header, 'Bearer ') !== 0) {
|
||||
error_response('Invalid authorization format. Expected: Bearer {api_key}', 401);
|
||||
}
|
||||
|
||||
$token = substr($header, 7);
|
||||
|
||||
if (ADMIN_API_KEY === 'CHANGE_ME_PLACEHOLDER') {
|
||||
app_log('WARNING', '[WARNING] Admin API key is not configured (still placeholder)');
|
||||
error_response('Admin API key not configured on server', 500);
|
||||
}
|
||||
|
||||
if (!hash_equals(ADMIN_API_KEY, $token)) {
|
||||
app_log('WARNING', '[WARNING] Invalid admin API key attempt from ' . (get_client_ip() ?? 'unknown'));
|
||||
error_response('Invalid API key', 401);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/quotes
|
||||
*
|
||||
* List quotes with pagination and optional filters.
|
||||
* Query params: skip, limit, status, search
|
||||
*/
|
||||
function handle_list_quotes(): void
|
||||
{
|
||||
check_admin_auth();
|
||||
$db = get_db();
|
||||
|
||||
$skip = max(0, (int)($_GET['skip'] ?? 0));
|
||||
$limit = min(1000, max(1, (int)($_GET['limit'] ?? 100)));
|
||||
$status = $_GET['status'] ?? null;
|
||||
$search = $_GET['search'] ?? null;
|
||||
|
||||
// Validate status if provided
|
||||
if ($status !== null && $status !== '' && !in_array($status, VALID_STATUSES, true)) {
|
||||
error_response("Invalid status filter: {$status}", 400);
|
||||
}
|
||||
|
||||
$result = list_quotes($db, $skip, $limit, $status, $search);
|
||||
|
||||
json_response([
|
||||
'total' => $result['total'],
|
||||
'skip' => $skip,
|
||||
'limit' => $limit,
|
||||
'quotes' => $result['quotes'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/quotes/stats
|
||||
*
|
||||
* Get dashboard statistics for quotes.
|
||||
*/
|
||||
function handle_get_stats(): void
|
||||
{
|
||||
check_admin_auth();
|
||||
$db = get_db();
|
||||
|
||||
$stats = get_stats($db);
|
||||
json_response($stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/quotes/{id}
|
||||
*
|
||||
* Get a single quote by ID with items, activities, and notifications.
|
||||
*/
|
||||
function handle_admin_get_quote(string $quote_id): void
|
||||
{
|
||||
check_admin_auth();
|
||||
$db = get_db();
|
||||
|
||||
$quote = get_quote_by_id($db, $quote_id);
|
||||
$response = build_admin_quote_response($db, $quote);
|
||||
json_response($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /admin/quotes/{id}
|
||||
*
|
||||
* Update a quote's status and/or expiration (admin only).
|
||||
*/
|
||||
function handle_admin_update_quote(string $quote_id): void
|
||||
{
|
||||
check_admin_auth();
|
||||
$data = get_json_body();
|
||||
$db = get_db();
|
||||
|
||||
$quote = admin_update_quote($db, $quote_id, $data, 'admin');
|
||||
$response = build_admin_quote_response($db, $quote);
|
||||
json_response($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/quotes/{id}/sync-syncro
|
||||
*
|
||||
* Trigger a SyncroRMM sync for a quote.
|
||||
*/
|
||||
function handle_sync_syncro(string $quote_id): void
|
||||
{
|
||||
check_admin_auth();
|
||||
$db = get_db();
|
||||
|
||||
$quote = get_quote_by_id($db, $quote_id);
|
||||
$result = sync_quote_to_syncro($db, $quote);
|
||||
json_response($result);
|
||||
}
|
||||
183
projects/msp-tools/quote-wizard/php-api/api/routes/quotes.php
Normal file
183
projects/msp-tools/quote-wizard/php-api/api/routes/quotes.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
/**
|
||||
* Public quote route handlers.
|
||||
*
|
||||
* These endpoints do not require authentication. They allow prospects
|
||||
* to create, view, update, and submit quotes using an access token.
|
||||
*/
|
||||
|
||||
// Deny direct access
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
||||
http_response_code(403);
|
||||
exit('Direct access denied.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
require_once __DIR__ . '/../db.php';
|
||||
require_once __DIR__ . '/../services/quote_service.php';
|
||||
require_once __DIR__ . '/../services/email_service.php';
|
||||
|
||||
/**
|
||||
* POST /quotes
|
||||
*
|
||||
* Create a new quote draft. Returns the quote ID, access token, status, and
|
||||
* a success message. HTTP 201 on success.
|
||||
*/
|
||||
function handle_create_quote(): void
|
||||
{
|
||||
$data = get_json_body();
|
||||
$ip = get_client_ip();
|
||||
$ua = get_user_agent();
|
||||
$db = get_db();
|
||||
|
||||
// Validate employee_count if provided
|
||||
if (isset($data['employee_count'])) {
|
||||
$data['employee_count'] = (int)$data['employee_count'];
|
||||
if ($data['employee_count'] < 1) {
|
||||
error_response('employee_count must be >= 1', 422);
|
||||
}
|
||||
}
|
||||
|
||||
$quote = create_quote($db, $data, $ip, $ua);
|
||||
|
||||
json_response([
|
||||
'id' => $quote['id'],
|
||||
'access_token' => $quote['access_token'],
|
||||
'status' => $quote['status'],
|
||||
'message' => 'Quote created successfully. Use the access_token to access your quote.',
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /quotes/{token}
|
||||
*
|
||||
* Retrieve a quote by its access token. Returns the full quote with items.
|
||||
*/
|
||||
function handle_get_quote(string $token): void
|
||||
{
|
||||
$db = get_db();
|
||||
$quote = get_quote_by_token($db, $token);
|
||||
$response = build_quote_response($db, $quote);
|
||||
json_response($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /quotes/{token}
|
||||
*
|
||||
* Update a draft quote's fields and/or replace all items.
|
||||
*/
|
||||
function handle_update_quote(string $token): void
|
||||
{
|
||||
$data = get_json_body();
|
||||
$ip = get_client_ip();
|
||||
$db = get_db();
|
||||
|
||||
$quote = update_quote($db, $token, $data, $ip);
|
||||
$response = build_quote_response($db, $quote);
|
||||
json_response($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /quotes/{token}/items
|
||||
*
|
||||
* Add a single item to a draft quote. HTTP 201 on success.
|
||||
*/
|
||||
function handle_add_item(string $token): void
|
||||
{
|
||||
$data = get_json_body();
|
||||
$ip = get_client_ip();
|
||||
$db = get_db();
|
||||
|
||||
// Validate required item fields
|
||||
$errors = validate_required($data, ['category', 'product_code', 'product_name', 'unit_price']);
|
||||
if (!empty($errors)) {
|
||||
error_response('Validation error', 422, $errors);
|
||||
}
|
||||
|
||||
$quote = add_item($db, $token, $data, $ip);
|
||||
$response = build_quote_response($db, $quote);
|
||||
json_response($response, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /quotes/{token}/items/{item_id}
|
||||
*
|
||||
* Remove an item from a draft quote.
|
||||
*/
|
||||
function handle_remove_item(string $token, string $item_id): void
|
||||
{
|
||||
$ip = get_client_ip();
|
||||
$db = get_db();
|
||||
|
||||
$quote = remove_item($db, $token, $item_id, $ip);
|
||||
$response = build_quote_response($db, $quote);
|
||||
json_response($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /quotes/{token}/submit
|
||||
*
|
||||
* Submit a draft quote with contact information. Sends an email notification
|
||||
* to the admin (best-effort -- email failure does not fail the submission).
|
||||
*/
|
||||
function handle_submit_quote(string $token): void
|
||||
{
|
||||
$data = get_json_body();
|
||||
$ip = get_client_ip();
|
||||
$db = get_db();
|
||||
|
||||
// Validate required submission fields
|
||||
$errors = validate_required($data, ['company_name', 'contact_name', 'contact_email']);
|
||||
if (!empty($errors)) {
|
||||
error_response('Validation error', 422, $errors);
|
||||
}
|
||||
|
||||
if (!validate_email($data['contact_email'])) {
|
||||
error_response('Invalid email address', 422, ["Field 'contact_email' is not a valid email."]);
|
||||
}
|
||||
|
||||
// Submit the quote (updates DB)
|
||||
$quote = submit_quote($db, $token, $data, $ip);
|
||||
|
||||
// Send email notification (best-effort, do not fail the request)
|
||||
try {
|
||||
$items_raw = fetch_items_for_quote($db, $quote['id']);
|
||||
$items_data = array_map(function ($item) {
|
||||
return [
|
||||
'service_name' => $item['product_name'],
|
||||
'billing_frequency' => $item['billing_frequency'],
|
||||
'unit_price' => $item['unit_price'],
|
||||
'quantity' => (int)$item['quantity'],
|
||||
];
|
||||
}, $items_raw);
|
||||
|
||||
$html = build_quote_notification_html(
|
||||
$data['company_name'],
|
||||
$data['contact_name'],
|
||||
$data['contact_email'],
|
||||
$data['contact_phone'] ?? null,
|
||||
number_format((float)$quote['monthly_total'], 2, '.', ''),
|
||||
number_format((float)$quote['setup_total'], 2, '.', ''),
|
||||
$items_data,
|
||||
$data['notes'] ?? null
|
||||
);
|
||||
|
||||
$subject = "New Quote Submission: {$data['company_name']} - \$" .
|
||||
number_format((float)$quote['monthly_total'], 2, '.', '') . "/mo";
|
||||
|
||||
$sent = send_email(ADMIN_NOTIFICATION_EMAIL, $subject, $html);
|
||||
|
||||
// Update notification record with result
|
||||
$notif_status = $sent ? 'sent' : 'failed';
|
||||
$notif_error = $sent ? null : 'Graph API send failed';
|
||||
update_notification_status($db, $quote['id'], $notif_status, $notif_error);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
app_log('ERROR', '[ERROR] Failed to send quote notification email: ' . $e->getMessage());
|
||||
// Do not fail the submission
|
||||
}
|
||||
|
||||
// Return the full quote response
|
||||
$response = build_quote_response($db, $quote);
|
||||
json_response($response);
|
||||
}
|
||||
Reference in New Issue
Block a user