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:
24
projects/msp-tools/quote-wizard/php-api/api/.htaccess
Normal file
24
projects/msp-tools/quote-wizard/php-api/api/.htaccess
Normal file
@@ -0,0 +1,24 @@
|
||||
RewriteEngine On
|
||||
|
||||
# Pass Authorization header through CGI/suPHP
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Handle CORS preflight requests
|
||||
RewriteCond %{REQUEST_METHOD} OPTIONS
|
||||
RewriteRule ^(.*)$ index.php [QSA,L]
|
||||
|
||||
# Route all requests to index.php unless the file or directory exists
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.*)$ index.php [QSA,L]
|
||||
|
||||
# Deny access to PHP files other than index.php
|
||||
<FilesMatch "^(?!index\.php$).+\.php$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
51
projects/msp-tools/quote-wizard/php-api/api/config.php
Normal file
51
projects/msp-tools/quote-wizard/php-api/api/config.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* Configuration for MSP Quote Wizard PHP API.
|
||||
*
|
||||
* All credentials and settings are defined here. On cPanel, this file
|
||||
* should be outside the web root or protected via .htaccess.
|
||||
*/
|
||||
|
||||
// Deny direct access
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
||||
http_response_code(403);
|
||||
exit('Direct access denied.');
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Database
|
||||
// --------------------------------------------------------------------------
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_NAME', 'azcomputerguru_acg2025');
|
||||
define('DB_USER', 'azcomputerguru_acg2025');
|
||||
define('DB_PASS', 'Kg-.v?{jFXSH');
|
||||
define('DB_CHARSET', 'utf8mb4');
|
||||
define('DB_TABLE_PREFIX', 'acgq_');
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Microsoft Graph API (email sending)
|
||||
// --------------------------------------------------------------------------
|
||||
define('GRAPH_TENANT_ID', 'ce61461e-81a0-4c84-bb4a-7b354a9a356d');
|
||||
define('GRAPH_CLIENT_ID', '15b0fafb-ab51-4cc9-adc7-f6334c805c22');
|
||||
define('GRAPH_CLIENT_SECRET', 'rRN8Q~FPfSL8O24iZthi_LVJTjGOCZG.DnxGHaSk');
|
||||
define('GRAPH_SENDER_EMAIL', 'noreply@azcomputerguru.com');
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Admin / Auth
|
||||
// --------------------------------------------------------------------------
|
||||
define('ADMIN_NOTIFICATION_EMAIL', 'mike@azcomputerguru.com');
|
||||
define('ADMIN_API_KEY', 'RqzhynUHgKxXaQTVFiM9TQyl8C3riuJu4Z_wwt6IGN0');
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Application
|
||||
// --------------------------------------------------------------------------
|
||||
define('QUOTE_DRAFT_EXPIRY_DAYS', 30);
|
||||
define('QUOTE_SUBMITTED_EXPIRY_DAYS', 90);
|
||||
|
||||
// CORS allowed origins (comma-separated or '*' for dev)
|
||||
define('CORS_ALLOWED_ORIGINS', 'https://azcomputerguru.com,https://www.azcomputerguru.com');
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Logging
|
||||
// --------------------------------------------------------------------------
|
||||
define('LOG_FILE', __DIR__ . '/../logs/api.log');
|
||||
55
projects/msp-tools/quote-wizard/php-api/api/db.php
Normal file
55
projects/msp-tools/quote-wizard/php-api/api/db.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/**
|
||||
* PDO database connection singleton.
|
||||
*
|
||||
* Provides a lazy-loaded PDO instance configured for the quote wizard
|
||||
* database with utf8mb4, exception error mode, and associative fetch.
|
||||
*/
|
||||
|
||||
// Deny direct access
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
||||
http_response_code(403);
|
||||
exit('Direct access denied.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
/**
|
||||
* Return a shared PDO connection instance.
|
||||
*
|
||||
* The connection is created on first call and reused for the lifetime
|
||||
* of the request. Uses utf8mb4 charset, ERRMODE_EXCEPTION, and
|
||||
* FETCH_ASSOC as the default fetch mode.
|
||||
*
|
||||
* @return PDO
|
||||
* @throws RuntimeException If the connection cannot be established.
|
||||
*/
|
||||
function get_db(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
|
||||
if ($pdo !== null) {
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;dbname=%s;charset=%s',
|
||||
DB_HOST,
|
||||
DB_NAME,
|
||||
DB_CHARSET
|
||||
);
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES 'utf8mb4'",
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
app_log('ERROR', 'Database connection failed: ' . $e->getMessage());
|
||||
throw new RuntimeException('Database connection failed');
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
277
projects/msp-tools/quote-wizard/php-api/api/helpers.php
Normal file
277
projects/msp-tools/quote-wizard/php-api/api/helpers.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared utility functions for the MSP Quote Wizard API.
|
||||
*
|
||||
* Provides UUID generation, token generation, JSON response helpers,
|
||||
* input validation, CORS headers, and logging.
|
||||
*/
|
||||
|
||||
// Deny direct access
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
||||
http_response_code(403);
|
||||
exit('Direct access denied.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// UUID / Token generation
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a UUID v4 string (lowercase, 36 chars with hyphens).
|
||||
*
|
||||
* Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
* where y is one of 8, 9, a, b.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
function generate_uuid(): string
|
||||
{
|
||||
$bytes = random_bytes(16);
|
||||
|
||||
// Set version to 4 (0100 in binary)
|
||||
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
|
||||
// Set variant to RFC 4122 (10xx in binary)
|
||||
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
|
||||
|
||||
return sprintf(
|
||||
'%s-%s-%s-%s-%s',
|
||||
bin2hex(substr($bytes, 0, 4)),
|
||||
bin2hex(substr($bytes, 4, 2)),
|
||||
bin2hex(substr($bytes, 6, 2)),
|
||||
bin2hex(substr($bytes, 8, 2)),
|
||||
bin2hex(substr($bytes, 10, 6))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL-safe access token matching Python's secrets.token_urlsafe(32).
|
||||
*
|
||||
* Produces a 43-character base64url-encoded string (no padding) from 32
|
||||
* random bytes, exactly matching the Python implementation.
|
||||
*
|
||||
* @return string 43-character URL-safe token
|
||||
*/
|
||||
function generate_access_token(): string
|
||||
{
|
||||
$bytes = random_bytes(32);
|
||||
// base64url encode: replace +/ with -_, strip padding =
|
||||
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// JSON response helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a JSON response with the given data and HTTP status code.
|
||||
*
|
||||
* Sets Content-Type header, outputs JSON, and terminates the script.
|
||||
*
|
||||
* @param mixed $data Data to encode as JSON.
|
||||
* @param int $status HTTP status code (default 200).
|
||||
* @return never
|
||||
*/
|
||||
function json_response($data, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON error response.
|
||||
*
|
||||
* @param string $message Error message.
|
||||
* @param int $status HTTP status code (default 400).
|
||||
* @param mixed|null $details Additional error details.
|
||||
* @return never
|
||||
*/
|
||||
function error_response(string $message, int $status = 400, $details = null): void
|
||||
{
|
||||
$body = ['detail' => $message];
|
||||
if ($details !== null) {
|
||||
$body['errors'] = $details;
|
||||
}
|
||||
json_response($body, $status);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Request parsing
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse the JSON request body.
|
||||
*
|
||||
* @return array Decoded JSON as an associative array.
|
||||
*/
|
||||
function get_json_body(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
if (empty($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
error_response('Invalid JSON in request body', 400);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client IP address, accounting for reverse proxies.
|
||||
*
|
||||
* Checks X-Forwarded-For first, then X-Real-IP, then REMOTE_ADDR.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
function get_client_ip(): ?string
|
||||
{
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
return trim($parts[0]);
|
||||
}
|
||||
|
||||
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
return trim($_SERVER['HTTP_X_REAL_IP']);
|
||||
}
|
||||
|
||||
return $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the User-Agent header value.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
function get_user_agent(): ?string
|
||||
{
|
||||
return $_SERVER['HTTP_USER_AGENT'] ?? null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CORS
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Emit CORS headers based on the configured allowed origins.
|
||||
*
|
||||
* For preflight (OPTIONS) requests, this also sets the allowed methods
|
||||
* and headers, then terminates the script with 204.
|
||||
*/
|
||||
function cors_headers(): void
|
||||
{
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed = array_map('trim', explode(',', CORS_ALLOWED_ORIGINS));
|
||||
|
||||
// Allow the origin if it matches our whitelist, or allow all if '*'
|
||||
if (in_array('*', $allowed, true) || in_array($origin, $allowed, true)) {
|
||||
$send_origin = in_array('*', $allowed, true) ? '*' : $origin;
|
||||
header("Access-Control-Allow-Origin: {$send_origin}");
|
||||
}
|
||||
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
|
||||
header('Access-Control-Max-Age: 86400');
|
||||
|
||||
// Handle preflight
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Validation
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate that all required fields are present and non-empty in the data.
|
||||
*
|
||||
* @param array $data Associative array of input data.
|
||||
* @param string[] $fields List of required field names.
|
||||
* @return string[] Array of error messages (empty if valid).
|
||||
*/
|
||||
function validate_required(array $data, array $fields): array
|
||||
{
|
||||
$errors = [];
|
||||
foreach ($fields as $field) {
|
||||
if (!isset($data[$field]) || (is_string($data[$field]) && trim($data[$field]) === '')) {
|
||||
$errors[] = "Field '{$field}' is required.";
|
||||
}
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an email address.
|
||||
*
|
||||
* @param string $email Email address to validate.
|
||||
* @return bool True if valid.
|
||||
*/
|
||||
function validate_email(string $email): bool
|
||||
{
|
||||
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Logging
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Append a message to the application log file.
|
||||
*
|
||||
* @param string $level Log level (INFO, WARNING, ERROR).
|
||||
* @param string $message Log message.
|
||||
*/
|
||||
function app_log(string $level, string $message): void
|
||||
{
|
||||
$dir = dirname(LOG_FILE);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0750, true);
|
||||
}
|
||||
|
||||
$timestamp = gmdate('Y-m-d\TH:i:s\Z');
|
||||
$line = "[{$timestamp}] [{$level}] {$message}" . PHP_EOL;
|
||||
@file_put_contents(LOG_FILE, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Datetime helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a datetime value for JSON output (ISO 8601 format).
|
||||
*
|
||||
* Accepts a datetime string from MySQL (Y-m-d H:i:s) and returns
|
||||
* an ISO 8601 string, or null if input is null/empty.
|
||||
*
|
||||
* @param string|null $dt MySQL datetime string.
|
||||
* @return string|null ISO 8601 formatted string.
|
||||
*/
|
||||
function format_datetime(?string $dt): ?string
|
||||
{
|
||||
if ($dt === null || $dt === '' || $dt === '0000-00-00 00:00:00') {
|
||||
return null;
|
||||
}
|
||||
// MySQL DATETIME is already in UTC for this application
|
||||
$ts = strtotime($dt);
|
||||
if ($ts === false) {
|
||||
return null;
|
||||
}
|
||||
return gmdate('Y-m-d\TH:i:s\Z', $ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current UTC datetime in MySQL format.
|
||||
*
|
||||
* @return string Y-m-d H:i:s
|
||||
*/
|
||||
function utc_now(): string
|
||||
{
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
164
projects/msp-tools/quote-wizard/php-api/api/index.php
Normal file
164
projects/msp-tools/quote-wizard/php-api/api/index.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
/**
|
||||
* Front controller / router for the MSP Quote Wizard PHP API.
|
||||
*
|
||||
* All requests are routed here via .htaccess. Parses the URI and method,
|
||||
* emits CORS headers, then dispatches to the appropriate route handler.
|
||||
*
|
||||
* Route map:
|
||||
* POST /quotes -> create quote
|
||||
* GET /quotes/{token} -> get quote by token
|
||||
* PUT /quotes/{token} -> update quote
|
||||
* POST /quotes/{token}/items -> add item
|
||||
* DELETE /quotes/{token}/items/{id} -> remove item
|
||||
* POST /quotes/{token}/submit -> submit quote
|
||||
* GET /admin/quotes -> list quotes (auth)
|
||||
* GET /admin/quotes/stats -> get stats (auth)
|
||||
* GET /admin/quotes/{id} -> get quote by ID (auth)
|
||||
* PUT /admin/quotes/{id} -> update quote status (auth)
|
||||
* POST /admin/quotes/{id}/sync-syncro -> sync to Syncro (auth)
|
||||
*/
|
||||
|
||||
// Error reporting: log only, never display to client
|
||||
ini_set('display_errors', '0');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
|
||||
// Emit CORS headers on every request (handles OPTIONS preflight too)
|
||||
cors_headers();
|
||||
|
||||
// Parse request
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// Get the path relative to the API directory
|
||||
// Strip the script directory from REQUEST_URI to get the route path
|
||||
$request_uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
|
||||
// Determine the base path (the directory where index.php lives)
|
||||
$script_dir = dirname($_SERVER['SCRIPT_NAME']);
|
||||
if ($script_dir !== '/' && $script_dir !== '\\') {
|
||||
$path = substr($request_uri, strlen($script_dir));
|
||||
} else {
|
||||
$path = $request_uri;
|
||||
}
|
||||
|
||||
// Normalize: ensure leading slash, remove trailing slash (except root)
|
||||
$path = '/' . ltrim($path, '/');
|
||||
if ($path !== '/' && substr($path, -1) === '/') {
|
||||
$path = rtrim($path, '/');
|
||||
}
|
||||
|
||||
// Split path into segments for matching
|
||||
$segments = array_values(array_filter(explode('/', $path), function ($s) {
|
||||
return $s !== '';
|
||||
}));
|
||||
$seg_count = count($segments);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Route dispatch
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// -- Public quote routes: /quotes/... --
|
||||
if ($seg_count >= 1 && $segments[0] === 'quotes') {
|
||||
|
||||
require_once __DIR__ . '/routes/quotes.php';
|
||||
|
||||
// POST /quotes -> create
|
||||
if ($seg_count === 1 && $method === 'POST') {
|
||||
handle_create_quote();
|
||||
}
|
||||
|
||||
// GET /quotes/{token} -> get
|
||||
if ($seg_count === 2 && $method === 'GET') {
|
||||
handle_get_quote($segments[1]);
|
||||
}
|
||||
|
||||
// PUT /quotes/{token} -> update
|
||||
if ($seg_count === 2 && $method === 'PUT') {
|
||||
handle_update_quote($segments[1]);
|
||||
}
|
||||
|
||||
// POST /quotes/{token}/items -> add item
|
||||
if ($seg_count === 3 && $segments[2] === 'items' && $method === 'POST') {
|
||||
handle_add_item($segments[1]);
|
||||
}
|
||||
|
||||
// DELETE /quotes/{token}/items/{id} -> remove item
|
||||
if ($seg_count === 4 && $segments[2] === 'items' && $method === 'DELETE') {
|
||||
handle_remove_item($segments[1], $segments[3]);
|
||||
}
|
||||
|
||||
// POST /quotes/{token}/submit -> submit
|
||||
if ($seg_count === 3 && $segments[2] === 'submit' && $method === 'POST') {
|
||||
handle_submit_quote($segments[1]);
|
||||
}
|
||||
|
||||
// If we got here with a quotes path but no match, 404
|
||||
error_response('Not found', 404);
|
||||
}
|
||||
|
||||
// -- Admin routes: /admin/quotes/... --
|
||||
if ($seg_count >= 2 && $segments[0] === 'admin' && $segments[1] === 'quotes') {
|
||||
|
||||
require_once __DIR__ . '/routes/admin.php';
|
||||
|
||||
// GET /admin/quotes -> list
|
||||
if ($seg_count === 2 && $method === 'GET') {
|
||||
handle_list_quotes();
|
||||
}
|
||||
|
||||
// GET /admin/quotes/stats -> stats
|
||||
if ($seg_count === 3 && $segments[2] === 'stats' && $method === 'GET') {
|
||||
handle_get_stats();
|
||||
}
|
||||
|
||||
// GET /admin/quotes/{id} -> get by ID
|
||||
if ($seg_count === 3 && $segments[2] !== 'stats' && $method === 'GET') {
|
||||
handle_admin_get_quote($segments[2]);
|
||||
}
|
||||
|
||||
// PUT /admin/quotes/{id} -> admin update
|
||||
if ($seg_count === 3 && $method === 'PUT') {
|
||||
handle_admin_update_quote($segments[2]);
|
||||
}
|
||||
|
||||
// POST /admin/quotes/{id}/sync-syncro -> syncro sync
|
||||
if ($seg_count === 4 && $segments[3] === 'sync-syncro' && $method === 'POST') {
|
||||
handle_sync_syncro($segments[2]);
|
||||
}
|
||||
|
||||
// If we got here with an admin path but no match, 404
|
||||
error_response('Not found', 404);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Health check: GET /health
|
||||
// --------------------------------------------------------------------------
|
||||
if ($seg_count === 1 && $segments[0] === 'health' && $method === 'GET') {
|
||||
// Quick DB connectivity check
|
||||
try {
|
||||
require_once __DIR__ . '/db.php';
|
||||
$db = get_db();
|
||||
$db->query('SELECT 1');
|
||||
json_response(['status' => 'ok', 'database' => 'connected']);
|
||||
} catch (\Throwable $e) {
|
||||
json_response(['status' => 'error', 'database' => 'disconnected'], 503);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Root: GET /
|
||||
// --------------------------------------------------------------------------
|
||||
if ($seg_count === 0 && $method === 'GET') {
|
||||
json_response([
|
||||
'service' => 'MSP Quote Wizard API',
|
||||
'version' => '1.0.0',
|
||||
'status' => 'running',
|
||||
]);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 404 fallback
|
||||
// --------------------------------------------------------------------------
|
||||
error_response('Not found', 404);
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
/**
|
||||
* Email service using Microsoft Graph API.
|
||||
*
|
||||
* Sends email via M365 Graph API using client credentials flow (OAuth 2.0).
|
||||
* Used for quote submission notifications and other system emails.
|
||||
*
|
||||
* All HTTP calls use curl.
|
||||
*/
|
||||
|
||||
// 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';
|
||||
|
||||
// Token cache: persists across calls within a single request
|
||||
$_graph_token_cache = [
|
||||
'access_token' => null,
|
||||
'expires_at' => 0,
|
||||
];
|
||||
|
||||
/**
|
||||
* Obtain an access token from Azure AD using client credentials flow.
|
||||
*
|
||||
* Caches the token in a static variable and reuses it until 60 seconds
|
||||
* before expiry.
|
||||
*
|
||||
* @return string Bearer access token.
|
||||
* @throws RuntimeException If credentials are not configured or request fails.
|
||||
*/
|
||||
function get_graph_token(): string
|
||||
{
|
||||
global $_graph_token_cache;
|
||||
|
||||
// Return cached token if still valid (with 60s buffer)
|
||||
if (
|
||||
$_graph_token_cache['access_token'] !== null
|
||||
&& $_graph_token_cache['expires_at'] > time() + 60
|
||||
) {
|
||||
return $_graph_token_cache['access_token'];
|
||||
}
|
||||
|
||||
if (empty(GRAPH_TENANT_ID) || empty(GRAPH_CLIENT_ID) || GRAPH_CLIENT_SECRET === 'CHANGE_ME_PLACEHOLDER') {
|
||||
throw new RuntimeException('Microsoft Graph API credentials not configured');
|
||||
}
|
||||
|
||||
$token_url = "https://login.microsoftonline.com/" . GRAPH_TENANT_ID . "/oauth2/v2.0/token";
|
||||
|
||||
$post_fields = http_build_query([
|
||||
'client_id' => GRAPH_CLIENT_ID,
|
||||
'client_secret' => GRAPH_CLIENT_SECRET,
|
||||
'scope' => 'https://graph.microsoft.com/.default',
|
||||
'grant_type' => 'client_credentials',
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $token_url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $post_fields,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curl_error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
app_log('ERROR', "Graph token request failed (curl): {$curl_error}");
|
||||
throw new RuntimeException("Failed to obtain Graph token: {$curl_error}");
|
||||
}
|
||||
|
||||
if ($http_code !== 200) {
|
||||
app_log('ERROR', "Graph token request failed (HTTP {$http_code}): {$response}");
|
||||
throw new RuntimeException("Failed to obtain Graph token: HTTP {$http_code}");
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (empty($data['access_token'])) {
|
||||
app_log('ERROR', 'Graph token response missing access_token');
|
||||
throw new RuntimeException('Invalid Graph token response');
|
||||
}
|
||||
|
||||
$_graph_token_cache['access_token'] = $data['access_token'];
|
||||
$_graph_token_cache['expires_at'] = time() + (int)($data['expires_in'] ?? 3600);
|
||||
|
||||
return $data['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email via Microsoft Graph API.
|
||||
*
|
||||
* @param string $to_email Recipient email address.
|
||||
* @param string $subject Email subject.
|
||||
* @param string $body_html HTML body content.
|
||||
* @param string|null $cc_email Optional CC recipient.
|
||||
* @return bool True if sent successfully, false otherwise.
|
||||
*/
|
||||
function send_email(string $to_email, string $subject, string $body_html, ?string $cc_email = null): bool
|
||||
{
|
||||
if (GRAPH_CLIENT_SECRET === 'CHANGE_ME_PLACEHOLDER' || empty(GRAPH_TENANT_ID)) {
|
||||
app_log('WARNING', 'Graph API not configured - skipping email send');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$token = get_graph_token();
|
||||
} catch (RuntimeException $e) {
|
||||
app_log('ERROR', 'Cannot send email - token error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
$message = [
|
||||
'message' => [
|
||||
'subject' => $subject,
|
||||
'body' => [
|
||||
'contentType' => 'HTML',
|
||||
'content' => $body_html,
|
||||
],
|
||||
'toRecipients' => [
|
||||
['emailAddress' => ['address' => $to_email]],
|
||||
],
|
||||
],
|
||||
'saveToSentItems' => 'true',
|
||||
];
|
||||
|
||||
if ($cc_email !== null) {
|
||||
$message['message']['ccRecipients'] = [
|
||||
['emailAddress' => ['address' => $cc_email]],
|
||||
];
|
||||
}
|
||||
|
||||
$url = "https://graph.microsoft.com/v1.0/users/" . GRAPH_SENDER_EMAIL . "/sendMail";
|
||||
$json_body = json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $json_body,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
"Authorization: Bearer {$token}",
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curl_error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
app_log('ERROR', "Graph sendMail curl error: {$curl_error}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Graph sendMail returns 202 on success (no body)
|
||||
if ($http_code >= 200 && $http_code < 300) {
|
||||
app_log('INFO', "[OK] Email sent to {$to_email}: {$subject}");
|
||||
return true;
|
||||
}
|
||||
|
||||
app_log('ERROR', "[ERROR] Graph sendMail failed (HTTP {$http_code}): {$response}");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the HTML email body for a quote submission notification.
|
||||
*
|
||||
* Matches the exact template from the Python email_service.py implementation.
|
||||
*
|
||||
* @param string $company_name Company name.
|
||||
* @param string $contact_name Contact name.
|
||||
* @param string $contact_email Contact email address.
|
||||
* @param string|null $contact_phone Contact phone number.
|
||||
* @param string $monthly_total Formatted monthly total.
|
||||
* @param string $setup_total Formatted setup total.
|
||||
* @param array $items Array of item data (service_name, billing_frequency, unit_price, quantity).
|
||||
* @param string|null $notes Additional notes from the prospect.
|
||||
* @return string HTML email body.
|
||||
*/
|
||||
function build_quote_notification_html(
|
||||
string $company_name,
|
||||
string $contact_name,
|
||||
string $contact_email,
|
||||
?string $contact_phone,
|
||||
string $monthly_total,
|
||||
string $setup_total,
|
||||
array $items,
|
||||
?string $notes = null
|
||||
): string {
|
||||
|
||||
$items_html = '';
|
||||
foreach ($items as $item) {
|
||||
$freq = $item['billing_frequency'] ?? 'monthly';
|
||||
$freq_label = $freq === 'monthly' ? '/mo' : ' (one-time)';
|
||||
$qty = (int)($item['quantity'] ?? 1);
|
||||
$price = $item['unit_price'] ?? '0.00';
|
||||
$line_total = (float)$price * $qty;
|
||||
|
||||
$service_name = htmlspecialchars($item['service_name'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$price_formatted = htmlspecialchars($price, ENT_QUOTES, 'UTF-8');
|
||||
$line_formatted = number_format($line_total, 2, '.', ',');
|
||||
|
||||
$items_html .= "
|
||||
<tr>
|
||||
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb;\">{$service_name}</td>
|
||||
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;\">{$qty}</td>
|
||||
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;\">\${$price_formatted}{$freq_label}</td>
|
||||
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;\">\${$line_formatted}{$freq_label}</td>
|
||||
</tr>";
|
||||
}
|
||||
|
||||
$notes_section = '';
|
||||
if ($notes !== null && $notes !== '') {
|
||||
$notes_escaped = htmlspecialchars($notes, ENT_QUOTES, 'UTF-8');
|
||||
$notes_section = "
|
||||
<div style=\"margin-top: 20px; padding: 12px 16px; background: #f8f9fb; border-radius: 8px;\">
|
||||
<strong style=\"color: #333d49;\">Notes:</strong>
|
||||
<p style=\"margin: 4px 0 0; color: #555;\">{$notes_escaped}</p>
|
||||
</div>";
|
||||
}
|
||||
|
||||
$phone_line = $contact_phone ? '<br>Phone: ' . htmlspecialchars($contact_phone, ENT_QUOTES, 'UTF-8') : '';
|
||||
$contact_name_escaped = htmlspecialchars($contact_name, ENT_QUOTES, 'UTF-8');
|
||||
$company_escaped = htmlspecialchars($company_name, ENT_QUOTES, 'UTF-8');
|
||||
$email_escaped = htmlspecialchars($contact_email, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$setup_section = '';
|
||||
if ((float)($setup_total ?? 0) > 0) {
|
||||
$setup_section = "<div style='background: #fff7ed; border-radius: 8px; padding: 12px 20px; margin-bottom: 20px;'><span style=\"color: #9a3412; font-size: 14px;\">One-Time Costs: <strong>\${$setup_total}</strong></span></div>";
|
||||
}
|
||||
|
||||
return "
|
||||
<div style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;\">
|
||||
<div style=\"background: linear-gradient(135deg, #333d49, #113559); padding: 24px 32px; border-radius: 12px 12px 0 0;\">
|
||||
<h1 style=\"color: white; margin: 0; font-size: 22px;\">New Quote Submission</h1>
|
||||
<p style=\"color: rgba(255,255,255,0.7); margin: 4px 0 0; font-size: 14px;\">Arizona Computer Guru - MSP Quote Wizard</p>
|
||||
</div>
|
||||
|
||||
<div style=\"padding: 24px 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;\">
|
||||
<div style=\"margin-bottom: 20px;\">
|
||||
<h2 style=\"color: #333d49; font-size: 18px; margin: 0 0 8px;\">Contact Information</h2>
|
||||
<p style=\"margin: 0; color: #555; line-height: 1.6;\">
|
||||
<strong>{$contact_name_escaped}</strong><br>
|
||||
{$company_escaped}<br>
|
||||
Email: <a href=\"mailto:{$email_escaped}\">{$email_escaped}</a>
|
||||
{$phone_line}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style=\"background: linear-gradient(135deg, #333d49, #113559); border-radius: 8px; padding: 16px 20px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;\">
|
||||
<span style=\"color: rgba(255,255,255,0.8); font-size: 14px;\">Monthly Total</span>
|
||||
<span style=\"color: white; font-size: 24px; font-weight: bold;\">\${$monthly_total}/mo</span>
|
||||
</div>
|
||||
|
||||
{$setup_section}
|
||||
|
||||
<h3 style=\"color: #333d49; font-size: 16px; margin: 20px 0 8px;\">Services</h3>
|
||||
<table style=\"width: 100%; border-collapse: collapse; font-size: 14px;\">
|
||||
<thead>
|
||||
<tr style=\"background: #f8f9fb;\">
|
||||
<th style=\"padding: 8px 12px; text-align: left; color: #333d49;\">Service</th>
|
||||
<th style=\"padding: 8px 12px; text-align: center; color: #333d49;\">Qty</th>
|
||||
<th style=\"padding: 8px 12px; text-align: right; color: #333d49;\">Unit Price</th>
|
||||
<th style=\"padding: 8px 12px; text-align: right; color: #333d49;\">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{$items_html}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{$notes_section}
|
||||
|
||||
<div style=\"margin-top: 24px; padding-top: 16px; border-top: 2px solid #fe7400; text-align: center;\">
|
||||
<p style=\"color: #999; font-size: 12px; margin: 0;\">
|
||||
Submitted via <a href=\"https://azcomputerguru.com/quote\" style=\"color: #fe7400;\">azcomputerguru.com/quote</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/**
|
||||
* Syncro RMM integration service (stub).
|
||||
*
|
||||
* This is a placeholder for the SyncroRMM lead creation and customer
|
||||
* lookup functionality. The full implementation will be added when
|
||||
* Syncro API credentials and endpoint details are finalized.
|
||||
*/
|
||||
|
||||
// Deny direct access
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
||||
http_response_code(403);
|
||||
exit('Direct access denied.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
/**
|
||||
* Sync a quote to SyncroRMM as a lead.
|
||||
*
|
||||
* Checks for an existing customer by email/business name, then creates
|
||||
* a lead in Syncro with the quote details.
|
||||
*
|
||||
* @param PDO $db Database connection.
|
||||
* @param array $quote Quote row from database.
|
||||
* @return array Result with keys: synced, is_existing_customer, syncro_lead_id, error
|
||||
*/
|
||||
function sync_quote_to_syncro(PDO $db, array $quote): array
|
||||
{
|
||||
$result = [
|
||||
'synced' => false,
|
||||
'is_existing_customer' => false,
|
||||
'syncro_lead_id' => null,
|
||||
'error' => 'Syncro integration not yet configured',
|
||||
];
|
||||
|
||||
if (empty($quote['contact_email'])) {
|
||||
$result['error'] = 'Quote has no contact email';
|
||||
return $result;
|
||||
}
|
||||
|
||||
app_log('INFO', "Syncro sync requested for quote {$quote['id']} - integration not yet configured");
|
||||
|
||||
return $result;
|
||||
}
|
||||
Reference in New Issue
Block a user