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) { error_log('Database connection failed: ' . $e->getMessage()); http_response_code(500); exit('Service unavailable'); } $app_id_raw = $_GET['id'] ?? ''; $token = $_GET['token'] ?? ''; $app_id = preg_match('/^\d+$/', $app_id_raw) ? $app_id_raw : '0'; // Verify token (optional: match your token logic) $stmt = $pdo->prepare("SELECT client_email, reference, created_at, submission_date, required_by, clock_paused, clock_paused_at, clock_pause_reason FROM applications WHERE id = ?"); $stmt->execute([$app_id]); $app = $stmt->fetch(PDO::FETCH_ASSOC); if (!$app) { http_response_code(404); exit("Application not found."); } // Fetch stages $stmt = $pdo->prepare("SELECT * FROM application_stages WHERE application_id = ? ORDER BY position ASC"); $stmt->execute([$app_id]); $stages = $stmt->fetchAll(PDO::FETCH_ASSOC); $totalStages = 7; $currentStage = count(array_filter($stages, function ($s) { return strtolower(trim($s['status'] ?? '')) === 'complete'; })); $progress = round(($currentStage / $totalStages) * 100); $isPaused = (int)($app['clock_paused'] ?? 0) === 1; $pauseReason = trim((string)($app['clock_pause_reason'] ?? '')); $decisionDate = null; // 1) Look for an explicit 'Council Decision Due' stage date $decisionStage = null; foreach ($stages as $s) { if (stripos($s['title'] ?? '', 'decision') !== false && !empty($s['stage_date'])) { $decisionStage = $s; break; } } if ($decisionStage) { $decisionDate = new DateTime($decisionStage['stage_date'], new DateTimeZone('Australia/Hobart')); } elseif (!empty($app['required_by'])) { $decisionDate = new DateTime($app['required_by'], new DateTimeZone('Australia/Hobart')); } elseif (!empty($app['submission_date'])) { $decisionDate = (new DateTime($app['submission_date'], new DateTimeZone('Australia/Hobart')))->modify('+42 days'); } // set a friendly “end of business day” time so the countdown isn’t midnight-awkward if ($decisionDate) { $decisionDate->setTime(17, 0, 0); } $decisionIso = $decisionDate ? $decisionDate->format('c') : ''; // --- Create correspondence entry --- if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'add_correspondence') { $tz = new DateTimeZone('Australia/Hobart'); $typeAllow = ['incoming','outgoing','note']; $channelAllow = ['email','phone','portal','letter','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 ]); // Redirect to avoid resubmission and jump to the timeline section header("Location: ".$_SERVER['REQUEST_URI']."#correspondence"); exit; } // Fetch timeline (newest first; pinned first) $stmt = $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 200 "); $stmt->execute([$app_id]); $correspondence = $stmt->fetchAll(PDO::FETCH_ASSOC); /* NEW: attachment counts (and optional details) */ $fileCounts = []; $filesByCorr = []; // if you also want to list the files if (!empty($correspondence)) { $ids = array_column($correspondence, 'id'); $ph = implode(',', array_fill(0, count($ids), '?')); // Count files per correspondence $qc = $pdo->prepare(" SELECT correspondence_id, COUNT(*) AS n FROM application_correspondence_files WHERE correspondence_id IN ($ph) GROUP BY correspondence_id "); $qc->execute($ids); foreach ($qc->fetchAll(PDO::FETCH_ASSOC) as $r) { $fileCounts[(int)$r['correspondence_id']] = (int)$r['n']; } // OPTIONAL: load file details if you want links $qd = $pdo->prepare(" SELECT correspondence_id, original_name, file_url FROM application_correspondence_files WHERE correspondence_id IN ($ph) ORDER BY id ASC "); $qd->execute($ids); foreach ($qd->fetchAll(PDO::FETCH_ASSOC) as $f) { $cid = (int)$f['correspondence_id']; if (!isset($filesByCorr[$cid])) $filesByCorr[$cid] = []; $filesByCorr[$cid][] = $f; } } // ------------------ HELPERS ------------------ function render_body_html(string $text): string { // escape first $s = htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); // linkify http(s) $s = preg_replace('~(https?://[^\s<]+)~i', '$1', $s); // newlines to
return nl2br($s); } function stage_icon_for(string $title): string { $t = strtolower($title); if (str_contains($t,'submit')) return 'bi-upload'; if (str_contains($t,'ack')) return 'bi-inbox'; if (str_contains($t,'fee') || str_contains($t,'paid')) return 'bi-cash-coin'; if (str_contains($t,'valid') || str_contains($t,'confirm')) return 'bi-check2-circle'; if (str_contains($t,'advert')) return 'bi-megaphone'; if (str_contains($t,'decision')) return 'bi-check-circle-fill'; return 'bi-flag'; } // Admin bypass: valid HTTP Basic Auth skips the client token check $_au = $cfg['admin_user'] ?? ''; $_ap = $cfg['admin_pass'] ?? ''; $isAdmin = $_au !== '' && $_ap !== '' && isset($_SERVER['PHP_AUTH_USER']) && $_SERVER['PHP_AUTH_USER'] === $_au && ($_SERVER['PHP_AUTH_PW'] ?? '') === $_ap; if (!$isAdmin) { // --- Require signed token from Contracts Admin link --- $clientid = $_GET['clientid'] ?? ''; $token = $_GET['token'] ?? ''; if (!preg_match('/^[A-Za-z0-9_-]+$/', $clientid)) { http_response_code(400); exit('Bad link (clientid).'); } if ($token === '') { header('WWW-Authenticate: Basic realm="Modulos Contracts Admin"'); http_response_code(403); exit('Missing token.'); } // Build expected token from the .md front matter secret $expected = progress_expected_token($clientid, $app_id); if (!$expected || !hash_equals($expected, $token)) { http_response_code(403); exit('Invalid or expired link.'); } } unset($_au, $_ap); ?> <?= htmlspecialchars($app['reference']) ?> – Application Progress

trim($row['title'] ?? '') ?: ('Stage ' . (string)($row['position'] + 1)), 'status' => in_array(strtolower($row['status'] ?? ''), ['complete','current','pending'], true) ? strtolower($row['status']) : 'pending', 'date' => $row['stage_date'] ?: ($row['updated_at'] ?: ($row['created_at'] ?? null)), ]; } // Ensure we always have a “current” $hasCurrent = false; foreach ($steps as $s) { if (($s['status'] ?? '') === 'current') { $hasCurrent = true; break; } } if (!$hasCurrent && !empty($steps)) { $lastComplete = -1; foreach ($steps as $i => $s) if (($s['status'] ?? '') === 'complete') $lastComplete = $i; if ($lastComplete >= 0) $steps[$lastComplete]['status'] = 'current'; else $steps[0]['status'] = 'current'; } // formatter $fmtDate = function ($v) { $t = $v ? strtotime($v) : 0; return $t ? date('D d M y', $t) : ''; }; ?>
This application has not started yet.

Timeline of Correspondence

'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' ]; $typeLabel = ['incoming'=>'Incoming','outgoing'=>'Outgoing','note'=>'Note']; $chLabel = ['email'=>'Email','phone'=>'Phone','meeting'=>'Meeting','other'=>'Other']; foreach ($correspondence as $row): $id = (int)$row['id']; $typeVal = strtolower(trim($row['type'] ?? 'note')); $channelVal = strtolower(trim($row['channel'] ?? 'other')); $key = ($typeVal === 'note') ? 'note' : "{$channelVal}_{$typeVal}"; $icon = $badgeMap[$key] ?? ($fallbackByChannel[$channelVal] ?? 'bi-journal-text'); $when = (new DateTime($row['event_at'], new DateTimeZone('Australia/Hobart')))->format('d M Y, h:ia'); $visBadge = $row['visibility']==='internal' ? ' Internal' : ''; $typeClass = 'type-'.preg_replace('/[^a-z]/','', $typeVal); $numFiles = $fileCounts[$id] ?? 0; // <- NEW $hasFiles = $numFiles > 0; // <- NEW ?>
via
No correspondence recorded yet.
$files): ?>