PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]; try { $pdo = new PDO($dsn, $cfg['db_username'], $cfg['db_password'], $options); } catch (PDOException $e) { exit('Database connection failed: ' . $e->getMessage()); } $app_id_raw = $_GET['id'] ?? ''; $app_id = preg_match('/^\d+$/', $app_id_raw) ? $app_id_raw : '0'; // Load existing stages for this application $rows = $pdo->prepare("SELECT * FROM application_stages WHERE application_id = ? ORDER BY position ASC, id ASC"); $rows->execute([$app_id]); $existing = []; foreach ($rows as $r) { $pos = is_null($r['position']) ? null : (int)$r['position']; if ($pos !== null) $existing[$pos] = $r; } $stmt = $pdo->prepare("SELECT * FROM applications WHERE id = ?"); $stmt->execute([$app_id]); $app = $stmt->fetch(); if (!$app) { http_response_code(404); exit("Application not found."); } // Pick the id that matches your contracts/{clientid}.md filename. $prefer = (string)($app['client_id'] ?? $app['clientid'] ?? $app_id); $prefer = preg_replace('/[^A-Za-z0-9_-]/', '', $prefer); $candidates = array_unique(array_filter([ $prefer, preg_replace('/[^A-Za-z0-9_-]/', '', (string)($app['reference'] ?? '')), ])); $progressUrl = ''; $progressErr = ''; $usedClientId = null; $clientEmail = trim((string)($app['client_email'] ?? '')); if (!filter_var($clientEmail, FILTER_VALIDATE_EMAIL)) { $clientEmail = ''; } foreach ($candidates as $cid) { if ($cid === '') continue; $md = contract_path($cid); if (is_file($md)) { $usedClientId = $cid; $clientEmail = ''; if ($usedClientId) { $meta = extract_front_matter_fields(contract_path($cid)); if (!empty($meta['client_email']) && filter_var($meta['client_email'], FILTER_VALIDATE_EMAIL)) { $clientEmail = trim($meta['client_email']); } } if (!$clientEmail) { $clientEmail = trim((string)($app['client_email'] ?? '')); } if (!filter_var($clientEmail, FILTER_VALIDATE_EMAIL)) { $clientEmail = ''; } try { $progressUrl = progress_public_url($cid, $app_id); } catch (Throwable $e) { $progressErr = $e->getMessage(); } break; } } if (!$progressUrl && !$progressErr) { $tried = []; foreach ($candidates as $cid) { $tried[] = contract_path($cid); } $progressErr = "Contract file not found. Tried: " . implode(' | ', $tried); } // Predefine default stages (can be adjusted per application later) $defaultStages = [ 'Submission to Council', 'Council Acknowledgement', 'Fees Paid', 'Confirmed Valid (42 Days Start)', 'Public Advertisement Start', 'Public Advertisement End', 'Council Decision Due' ]; // --- Create correspondence entry --- if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'add_correspondence') { $tz = new DateTimeZone('Australia/Hobart'); $typeAllow = ['incoming','outgoing','note']; $channelAllow = ['email','phone','meeting','other']; $visibilityAllow= ['client','internal']; $type = in_array($_POST['type'] ?? 'note', $typeAllow, true) ? $_POST['type'] : 'note'; $channel = in_array($_POST['channel'] ?? 'other', $channelAllow, true) ? $_POST['channel'] : 'other'; $visibility = in_array($_POST['visibility'] ?? 'client', $visibilityAllow, true) ? $_POST['visibility'] : 'client'; $subject = trim($_POST['subject'] ?? '') ?: null; $author = trim($_POST['author'] ?? '') ?: null; $pin = isset($_POST['pin']) ? 1 : 0; $bodyRaw = trim($_POST['body'] ?? ''); if ($bodyRaw === '') { $bodyRaw = '(no content)'; } // event_at: prefer user input, else "now" $eventAtRaw = trim($_POST['event_at'] ?? ''); try { $eventAt = $eventAtRaw ? new DateTime($eventAtRaw, $tz) : new DateTime('now', $tz); } catch (Exception $e) { $eventAt = new DateTime('now', $tz); } $stmt = $pdo->prepare(" INSERT INTO application_correspondence (application_id, event_at, type, channel, subject, body, author, visibility, pin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $app_id, $eventAt->format('Y-m-d H:i:s'), $type, $channel, $subject, $bodyRaw, $author, $visibility, $pin ]); $corrId = (int)$pdo->lastInsertId(); if (!empty($_FILES['attachments']) && is_array($_FILES['attachments']['name'])) { $finfo = new finfo(FILEINFO_MIME_TYPE); $allowed = ['application/pdf' => 'pdf']; $baseDir = rtrim(CORR_UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $app_id . DIRECTORY_SEPARATOR . $corrId; if (!is_dir($baseDir)) @mkdir($baseDir, 0775, true); $ins = $pdo->prepare(" INSERT INTO application_correspondence_files (application_id, correspondence_id, original_name, file_url, file_path, mime, size) VALUES (?, ?, ?, ?, ?, ?, ?) "); $names = $_FILES['attachments']['name']; $tmps = $_FILES['attachments']['tmp_name']; $errs = $_FILES['attachments']['error']; $sizes = $_FILES['attachments']['size']; for ($i = 0; $i < count($names); $i++) { if ($errs[$i] !== UPLOAD_ERR_OK || $tmps[$i] === '') continue; $mime = $finfo->file($tmps[$i]) ?: ''; if (!isset($allowed[$mime])) continue; // only PDFs // Safe filename $orig = preg_replace('/[^\w\.\- ]+/', '_', (string)$names[$i]); $slug = substr(sha1($orig . microtime(true)), 0, 10) . '.pdf'; $dest = $baseDir . DIRECTORY_SEPARATOR . $slug; if (move_uploaded_file($tmps[$i], $dest)) { $url = rtrim(CORR_UPLOAD_URL, '/') . '/' . rawurlencode((string)$app_id) . '/' . rawurlencode((string)$corrId) . '/' . rawurlencode($slug); $ins->execute([ $app_id, $corrId, $orig, $url, $dest, $mime, (int)$sizes[$i] ]); } } } // (keep the attachments block as-is above) $shouldNotify = !empty($_POST['notify_client']) && ($visibility === 'client'); error_log("notify? ".($shouldNotify?'yes':'no')." to='$clientEmail' url='$progressUrl'"); if ($shouldNotify) { /* $prefer = (string)($app['client_id'] ?? $app['clientid'] ?? $app_id); $prefer = preg_replace('/[^A-Za-z0-9_-]/', '', $prefer); $candidates = array_unique(array_filter([$prefer, preg_replace('/[^A-Za-z0-9_-]/', '', (string)($app['reference'] ?? ''))])); $clientid = null; foreach ($candidates as $cid) { if ($cid && is_file(contract_path($cid))) { $clientid = $cid; break; } } $progressUrl = $clientid ? progress_public_url($clientid, $app_id) : ''; */ $to = $clientEmail; if ($progressUrl && filter_var($to, FILTER_VALIDATE_EMAIL)) { $corrIdForMail = $corrId; // we just inserted it $qr = $pdo->prepare("SELECT original_name, file_url FROM application_correspondence_files WHERE correspondence_id = ? ORDER BY id ASC"); $qr->execute([$corrIdForMail]); $attRows = $qr->fetchAll(PDO::FETCH_ASSOC) ?: []; $atts = array_map(fn($a)=>['name'=>$a['original_name'], 'url'=>$a['file_url']], $attRows); $ok = send_progress_update_email( $to, $progressUrl, ($app['reference'] ?: $app_id), $cfg, [ 'when' => dt_human($eventAt->format('Y-m-d H:i:s')), 'type' => $type, 'channel' => $channel, 'subject' => $subject ?: ucfirst($type), 'author' => $author ?: '', 'body' => $bodyRaw, 'attachments' => $atts, ] ); // optional debug if (!$ok) error_log("update_correspondence: send_progress_update_email returned false (to=$to)"); } else { // optional debug error_log("update_correspondence: not sending (to='$to', url='$progressUrl')"); } } // Redirect to avoid resubmission and jump to the timeline section header("Location: ".$_SERVER['REQUEST_URI']."#correspondence"); exit; } // --- Update correspondence entry --- if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'update_correspondence') { $id = (int)($_POST['id'] ?? 0); if ($id > 0) { $tz = new DateTimeZone('Australia/Hobart'); $typeAllow = ['incoming','outgoing','note']; $channelAllow = ['email','phone','meeting','other']; $visibilityAllow= ['client','internal']; $type = in_array($_POST['type'] ?? 'note', $typeAllow, true) ? $_POST['type'] : 'note'; $channel = in_array($_POST['channel'] ?? 'other', $channelAllow, true) ? $_POST['channel'] : 'other'; $visibility = in_array($_POST['visibility'] ?? 'client', $visibilityAllow, true) ? $_POST['visibility'] : 'client'; $subject = trim($_POST['subject'] ?? '') ?: null; $author = trim($_POST['author'] ?? '') ?: null; $pin = isset($_POST['pin']) ? 1 : 0; $bodyRaw = trim($_POST['body'] ?? '') ?: '(no content)'; $eventAtRaw = trim($_POST['event_at'] ?? ''); try { $eventAt = $eventAtRaw ? new DateTime($eventAtRaw, $tz) : new DateTime('now', $tz); } catch (Exception $e) { $eventAt = new DateTime('now', $tz); } $stmt = $pdo->prepare(" UPDATE application_correspondence SET event_at=?, type=?, channel=?, subject=?, body=?, author=?, visibility=?, pin=?, updated_at=NOW() WHERE id=? AND application_id=? "); $stmt->execute([ $eventAt->format('Y-m-d H:i:s'), $type, $channel, $subject, $bodyRaw, $author, $visibility, $pin, $id, $app_id ]); // accept newly added PDFs on edit as well if (!empty($_FILES['attachments']) && is_array($_FILES['attachments']['name'])) { $corrId = $id; // we're editing this row $finfo = new finfo(FILEINFO_MIME_TYPE); $allowed = ['application/pdf' => 'pdf']; $baseDir = rtrim(CORR_UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $app_id . DIRECTORY_SEPARATOR . $corrId; if (!is_dir($baseDir)) @mkdir($baseDir, 0775, true); $ins = $pdo->prepare(" INSERT INTO application_correspondence_files (application_id, correspondence_id, original_name, file_url, file_path, mime, size) VALUES (?, ?, ?, ?, ?, ?, ?) "); $names = $_FILES['attachments']['name']; $tmps = $_FILES['attachments']['tmp_name']; $errs = $_FILES['attachments']['error']; $sizes = $_FILES['attachments']['size']; for ($i = 0; $i < count($names); $i++) { if ($errs[$i] !== UPLOAD_ERR_OK || $tmps[$i] === '') continue; $mime = $finfo->file($tmps[$i]) ?: ''; if (!isset($allowed[$mime])) continue; // PDF only $orig = preg_replace('/[^\w\.\- ]+/', '_', (string)$names[$i]); $slug = substr(sha1($orig . microtime(true)), 0, 10) . '.pdf'; $dest = $baseDir . DIRECTORY_SEPARATOR . $slug; if (move_uploaded_file($tmps[$i], $dest)) { $url = rtrim(CORR_UPLOAD_URL, '/') . '/' . rawurlencode((string)$app_id) . '/' . rawurlencode((string)$corrId) . '/' . rawurlencode($slug); $ins->execute([$app_id, $corrId, $orig, $url, $dest, $mime, (int)$sizes[$i]]); } } } // (keep the attachments block as-is above) $shouldNotify = !empty($_POST['notify_client']) && ($visibility === 'client'); if ($shouldNotify) { $prefer = (string)($app['client_id'] ?? $app['clientid'] ?? $app_id); $prefer = preg_replace('/[^A-Za-z0-9_-]/', '', $prefer); $candidates = array_unique(array_filter([$prefer, preg_replace('/[^A-Za-z0-9_-]/', '', (string)($app['reference'] ?? ''))])); $clientid = null; foreach ($candidates as $cid) { if ($cid && is_file(contract_path($cid))) { $clientid = $cid; break; } } $progressUrl = $clientid ? progress_public_url($clientid, $app_id) : ''; $to = $clientEmail; if ($progressUrl && filter_var($to, FILTER_VALIDATE_EMAIL)) { $corrIdForMail = $id; // we're editing this one $qr = $pdo->prepare("SELECT original_name, file_url FROM application_correspondence_files WHERE correspondence_id = ? ORDER BY id ASC"); $qr->execute([$corrIdForMail]); $attRows = $qr->fetchAll(PDO::FETCH_ASSOC) ?: []; $atts = array_map(fn($a)=>['name'=>$a['original_name'], 'url'=>$a['file_url']], $attRows); send_progress_update_email( $to, $progressUrl, ($app['reference'] ?: $app_id), $cfg, [ 'when' => dt_human($eventAt->format('Y-m-d H:i:s')), 'type' => $type, 'channel' => $channel, 'subject' => $subject ?: ucfirst($type), 'author' => $author ?: '', 'body' => $bodyRaw, 'attachments' => $atts, ] ); } } } header("Location: ".$_SERVER['REQUEST_URI']."#correspondence"); exit; } // ---- AJAX: send progress link ---- if (($_GET['action'] ?? $_POST['action'] ?? '') === 'send_progress_link') { // CSRF already validated at top $email = trim($_POST['email'] ?? ''); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { json_response(['ok' => false, 'error' => 'Invalid email'], 400); } // Build or reuse the progress URL $prefer = (string)($app['client_id'] ?? $app['clientid'] ?? $app_id); $prefer = preg_replace('/[^A-Za-z0-9_-]/', '', $prefer); $candidates = array_unique(array_filter([ $prefer, preg_replace('/[^A-Za-z0-9_-]/', '', (string)($app['reference'] ?? '')), ])); $clientid = null; foreach ($candidates as $cid) { if ($cid && is_file(contract_path($cid))) { $clientid = $cid; break; } } if (!$clientid) { $pathsTried = implode(' | ', array_map(fn($c)=>contract_path($c), $candidates)); json_response(['ok' => false, 'error' => "Contract file not found. Tried: {$pathsTried}"], 404); } try { $url = progress_public_url($clientid, $app_id); } catch (Throwable $e) { json_response(['ok' => false, 'error' => $e->getMessage()], 500); } $jobRefOrId = $app['reference'] ?: $app_id; $sent = send_progress_email($email, $url, $jobRefOrId, $cfg); if ($sent) { json_response(['ok' => true, 'url' => $url]); } else { json_response(['ok' => false, 'error' => 'Failed to send email'], 500); } } // Helpers function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } function excerpt($s, $n=180){ $s = trim(preg_replace('/\s+/', ' ', (string)$s)); return mb_strlen($s) > $n ? mb_substr($s,0,$n-1).'…' : $s; } function dt_local($mysql, $tz='Australia/Hobart'){ if (!$mysql) return ''; $d = new DateTime($mysql, new DateTimeZone($tz)); return $d->format('Y-m-d\TH:i'); } function dt_human($mysql, $tz='Australia/Hobart'){ if (!$mysql) return ''; $d = new DateTime($mysql, new DateTimeZone($tz)); return $d->format('D d M Y, h:ia'); } // Build contracts/{clientid}.md path function contract_path(string $clientid): string { $id = preg_replace('/[^A-Za-z0-9_-]/', '', $clientid); return rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . $id . '.md'; } // Tiny front-matter puller (same idea as contracts-admin) function extract_front_matter_fields(string $file): array { $out = []; $txt = @file_get_contents($file); if (!$txt) return $out; if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) return $out; $fm = $m[1]; // admin.secret inside an admin: block, or a flat admin_secret if (preg_match('/^\s*admin\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block)) { $adminBlock = $block[1]; if (preg_match('/^\s*secret\s*:\s*["\']?([^"\']+)["\']?/mi', $adminBlock, $mm)) { $out['admin_secret'] = trim($mm[1]); } } if (empty($out['admin_secret']) && preg_match('/^\s*admin_secret\s*:\s*["\']?([^"\']+)["\']?/mi', $fm, $mm)) { $out['admin_secret'] = trim($mm[1]); } // client.email inside a client: block, or flat client_email / email if (preg_match('/^\s*client\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block)) { $clientBlock = $block[1]; if (preg_match('/^\s*email\s*:\s*["\']?([^"\']+)["\']?/mi', $clientBlock, $mm)) { $out['client_email'] = trim($mm[1]); } } if (empty($out['client_email'])) { if (preg_match('/^\s*client_email\s*:\s*["\']?([^"\']+)["\']?/mi', $fm, $mm)) { $out['client_email'] = trim($mm[1]); } elseif (preg_match('/^\s*email\s*:\s*["\']?([^"\']+)["\']?/mi', $fm, $mm)) { $out['client_email'] = trim($mm[1]); } } return $out; } // Build the signed public progress URL (namespaced HMAC) function progress_public_url(string $clientid, $appId): string { $meta = extract_front_matter_fields(contract_path($clientid)); $secret = $meta['admin_secret'] ?? ''; if ($secret === '') { throw new RuntimeException("Missing admin secret for client ID: {$clientid}"); } $token = hash_hmac('sha256', 'progress|' . (string)$appId, $secret); $base = rtrim(PROGRESS_BASE_URL, '/'); return $base . '/progress.php?id=' . rawurlencode((string)$appId) . '&clientid=' . rawurlencode($clientid) . '&token=' . rawurlencode($token); } foreach ($candidates as $cid) { if ($cid === '') continue; $md = contract_path($cid); if (is_file($md)) { $usedClientId = $cid; $clientEmail = ''; if ($usedClientId) { $meta = extract_front_matter_fields(contract_path($cid)); if (!empty($meta['client_email']) && filter_var($meta['client_email'], FILTER_VALIDATE_EMAIL)) { $clientEmail = trim($meta['client_email']); } } if (!$clientEmail) { $clientEmail = trim((string)($app['client_email'] ?? '')); } if (!filter_var($clientEmail, FILTER_VALIDATE_EMAIL)) { $clientEmail = ''; } try { $progressUrl = progress_public_url($cid, $app_id); } catch (Throwable $e) { $progressErr = $e->getMessage(); } break; } } if (!$progressUrl && !$progressErr) { // Nothing matched; explain what we tried $tried = []; foreach ($candidates as $cid) { $tried[] = contract_path($cid); } $progressErr = "Contract file not found. Tried: " . implode(' | ', $tried); } $rows = $pdo->prepare(" SELECT id, event_at, type, channel, subject, body, author, visibility, pin, created_at FROM application_correspondence WHERE application_id = ? ORDER BY pin DESC, event_at DESC, id DESC LIMIT 30 "); $rows->execute([$app_id]); $correspondence = $rows->fetchAll(PDO::FETCH_ASSOC); // -------------------------------------------- EMAIL HELPERS ----------------------------------------- // embed a PNG data URL as CID (same helper you already have) function email_logo_png_cid(PHPMailer $mail, string $dataUrl, string $alt = 'Modulos Design', int $width = 200): string { if ($dataUrl === '') return ''; $prefix = 'data:image/png;base64,'; if (stripos($dataUrl, $prefix) !== 0) return ''; $bin = base64_decode(substr($dataUrl, strlen($prefix)), true); if ($bin === false) return ''; $cid = 'logo_' . substr(sha1($bin), 0, 12) . '@modulos'; $mail->addStringEmbeddedImage($bin, $cid, 'logo.png', 'base64', 'image/png'); return '' . htmlspecialchars($alt, ENT_QUOTES, 'UTF-8') . ''; } // email HTML body — same structure as contracts-admin but wording for “Progress” function build_progress_email_html_template(string $logoHtml, string $safeJob, string $firstNameSafe, string $safeUrl, string $safeCompany, string $safeSignature): string { return << Your application progress.
{$logoHtml} Council Application #{$safeJob}
Hello {$firstNameSafe},
View Progress
Thank you.

Kind Regards,

{$safeSignature}
Benjamin Harris
{$safeCompany}
0402 984 082  |  drafting@modulosdesign.com.au
This is an automated message. Please reply to this email if you have any questions.
HTML; } // send mail (PHPMailer, same SMTP pattern as contracts-admin) function send_progress_email(string $email, string $progressUrl, string $jobRefOrId, array $cfg): bool { $mail = new PHPMailer(true); $safeCompany = htmlspecialchars($cfg['company_name'] ?? 'Modulos Design', ENT_QUOTES, 'UTF-8'); $safeUrl = htmlspecialchars($progressUrl, ENT_QUOTES, 'UTF-8'); $safeJob = htmlspecialchars((string)$jobRefOrId, ENT_QUOTES, 'UTF-8'); $firstName = 'there'; if (!empty($cfg['client_name'])) { $firstName = htmlspecialchars($cfg['client_name'], ENT_QUOTES, 'UTF-8'); } $logoHtml = email_logo_png_cid($mail, $cfg['dark_logo'] ?? '', $safeCompany, 200); $signatureImg = email_logo_png_cid($mail, $cfg['dev_signature'] ?? '', $safeCompany, 100); $html = build_progress_email_html_template($logoHtml, $safeJob, $firstName, $safeUrl, $safeCompany, $signatureImg); $alt = "Hello {$firstName},\n\nYour application progress page is ready:\n{$progressUrl}\n\nKind Regards,\n{$safeCompany}"; // <- set these BEFORE the try so the fallback can use them $fromAddress = $cfg['smtp_from'] ?? 'no-reply@modulosdesign.com.au'; $fromName = $cfg['smtp_from_name'] ?? 'Modulos Design'; try { $mail->CharSet = 'UTF-8'; $mail->Encoding = 'base64'; $smtpHost = $cfg['smtp_host'] ?? ''; if ($smtpHost !== '') { $mail->isSMTP(); $mail->SMTPDebug = SMTP::DEBUG_OFF; $mail->Host = $smtpHost; $mail->SMTPAuth = true; $mail->Username = $cfg['smtp_username'] ?? ''; $mail->Password = $cfg['smtp_password'] ?? ''; $secure = strtolower((string)($cfg['smtp_secure'] ?? 'ssl')); if ($secure === 'ssl') { $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; $mail->Port = (int)($cfg['smtp_port'] ?? 465); } else { $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = (int)($cfg['smtp_port'] ?? 587); } } $mail->setFrom($fromAddress, $fromName); if (!empty($cfg['smtp_reply_to'])) $mail->addReplyTo($cfg['smtp_reply_to']); $mail->addAddress($email); if (!empty($cfg['smtp_bcc'])) { foreach (explode(',', $cfg['smtp_bcc']) as $bcc) { $bcc = trim($bcc); if ($bcc !== '') $mail->addBCC($bcc); } } $mail->addBCC('drafting@modulosdesign.com.au'); $mail->isHTML(true); $mail->Subject = "Your Application Progress Dashboard"; $mail->Body = $html; $mail->AltBody = $alt; $mail->send(); return true; } catch (Throwable $e) { error_log("send_progress_email failed for {$email}: ".$e->getMessage()); // Fallback to PHP mail() $headers = "MIME-Version: 1.0\r\n"; $headers .= "Content-type: text/html; charset=UTF-8\r\n"; $headers .= "From: {$fromName} <{$fromAddress}>\r\n"; return mail($email, "Your Application Progress Page", $html, $headers); } } function send_progress_update_email( string $email, string $progressUrl, string $jobRefOrId, array $cfg, array $update // ['when'=>..., 'type'=>..., 'channel'=>..., 'subject'=>..., 'author'=>..., 'body'=>..., 'attachments'=>[['name'=>..., 'url'=>...], ...]] ): bool { $mail = new PHPMailer(true); $safeCompany = htmlspecialchars($cfg['company_name'] ?? 'Modulos Design', ENT_QUOTES, 'UTF-8'); $safeUrl = htmlspecialchars($progressUrl, ENT_QUOTES, 'UTF-8'); $safeJob = htmlspecialchars((string)$jobRefOrId, ENT_QUOTES, 'UTF-8'); $firstName = htmlspecialchars($cfg['client_name'] ?? 'there', ENT_QUOTES, 'UTF-8'); $logoHtml = email_logo_png_cid($mail, $cfg['dark_logo'] ?? '', $safeCompany, 200); $signatureImg = email_logo_png_cid($mail, $cfg['dev_signature'] ?? '', $safeCompany, 100); $when = htmlspecialchars($update['when'] ?? '', ENT_QUOTES, 'UTF-8'); $subj = htmlspecialchars($update['subject'] ?? ucfirst($update['type'] ?? 'Update'), ENT_QUOTES, 'UTF-8'); $author = htmlspecialchars($update['author'] ?? '', ENT_QUOTES, 'UTF-8'); $body = nl2br(htmlspecialchars(mb_strimwidth((string)($update['body'] ?? ''), 0, 600, '…'), ENT_QUOTES, 'UTF-8')); // attachments list (as links) $attHtml = ''; if (!empty($update['attachments'])) { $attHtml .= ''; } $html = build_progress_email_html_template( $logoHtml, $safeJob, $firstName, $safeUrl, $safeCompany, $signatureImg ); // inject an “update” block right above the CTA (cheap & cheerful; keeps your template) $updateBlock = '' . '
New update posted
' . '
When: ' . $when . '
' . '
Subject: ' . $subj . '
' . ($author ? '
Author: ' . $author . '
' : '') . '
' . $body . '
' . ($attHtml ? '
Attachments:' . $attHtml . '
' : '') . ''; $html = str_replace( ''."\n".' '."\n".' CharSet = 'UTF-8'; $mail->Encoding = 'base64'; if (!empty($cfg['smtp_host'])) { $mail->isSMTP(); $mail->SMTPDebug = SMTP::DEBUG_OFF; $mail->Host = $cfg['smtp_host']; $mail->SMTPAuth = true; $mail->Username = $cfg['smtp_username'] ?? ''; $mail->Password = $cfg['smtp_password'] ?? ''; $secure = strtolower((string)($cfg['smtp_secure'] ?? 'ssl')); if ($secure === 'ssl') { $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; $mail->Port = (int)($cfg['smtp_port'] ?? 465); } else { $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = (int)($cfg['smtp_port'] ?? 587); } } $mail->setFrom($fromAddress, $fromName); if (!empty($cfg['smtp_reply_to'])) $mail->addReplyTo($cfg['smtp_reply_to']); $mail->addAddress($email); if (!empty($cfg['smtp_bcc'])) { foreach (explode(',', $cfg['smtp_bcc']) as $bcc) { $bcc = trim($bcc); if ($bcc !== '') $mail->addBCC($bcc); } } $mail->addBCC('drafting@modulosdesign.com.au'); $mail->isHTML(true); $mail->Subject = "Update posted – Application #{$jobRefOrId}"; $mail->Body = $html; $mail->AltBody = $alt; $mail->send(); return true; } catch (Throwable $e) { error_log("send_progress_update_email failed: ".$e->getMessage()); return false; } } // Load attachments for the visible correspondence list $attByCorr = []; if (!empty($correspondence)) { $ids = array_column($correspondence, 'id'); $ph = implode(',', array_fill(0, count($ids), '?')); $qr = $pdo->prepare(" SELECT id, correspondence_id, original_name, file_url FROM application_correspondence_files WHERE correspondence_id IN ($ph) ORDER BY id ASC "); $qr->execute($ids); foreach ($qr->fetchAll(PDO::FETCH_ASSOC) as $a) { $attByCorr[(int)$a['correspondence_id']][] = $a; } } ?> Edit Timeline – <?= htmlspecialchars($app['reference']) ?>

Edit Timeline for Job:

>

Milestones
$row) { $rowsOut[] = [ 'id' => $row['id'] ?? '', 'pos' => $pos++, 'title' => $row['title'] ?? ('Stage ' . ($i+1)), 'status' => $row['status'] ?? 'pending', 'date' => $row['stage_date'] ?? '', 'notes' => $row['description'] ?? '', 'pdf' => $row['pdf_path'] ?? '', ]; } // Pad with defaults if needed for ($i = count($rowsOut); $i < max(count($rowsOut), count($defaultStages)); $i++) { $rowsOut[] = [ 'id' => '', 'pos' => $i, 'title' => $defaultStages[$i] ?? ('Stage ' . ($i+1)), 'status' => 'pending', 'date' => '', 'notes' => '', 'pdf' => '', ]; } // Render foreach ($rowsOut as $r): ?>
# Stage Name Status Date Notes PDF
Current PDF
Back
Open progress

Add correspondence / note
Tip: click Try auto-parse to fill Subject/When from headers (Subject:, Date:, From:).
Drag & drop PDF here, or click to browse.
PDF only.
Sent if Client-visible '.h($clientEmail).'.' : '' ?>
Recent correspondence
shown
No correspondence yet.
'bi-envelope-arrow-up', 'email_outgoing' => 'bi-send-check', 'phone_incoming' => 'bi-telephone-inbound', 'phone_outgoing' => 'bi-telephone-outbound', 'note' => 'bi-journal-text', ]; $fallbackByChannel = [ 'email' => 'bi-envelope', 'phone' => 'bi-telephone', 'meeting' => 'bi-people', 'other' => 'bi-chat-dots', ]; foreach ($correspondence as $c): // per-row values $typeVal = strtolower(trim($c['type'] ?? 'note')); // incoming|outgoing|note $channelVal = strtolower(trim($c['channel'] ?? 'other')); // email|phone|meeting|other $key = ($typeVal === 'note') ? 'note' : "{$channelVal}_{$typeVal}"; $icon = $badgeMap[$key] ?? ($fallbackByChannel[$channelVal] ?? 'bi-journal-text'); $badge = $c['visibility']==='internal' ? 'Internal' : ''; $pin = $c['pin'] ? '' : ''; ?>