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 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); $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); } // --- 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 === '') { 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.'); } ?> <?= htmlspecialchars($app['reference']) ?> – Application Progress

= $N) continue; $st = strtolower(trim($row['status'] ?? 'pending')); if (!in_array($st, ['complete','current','pending'], true)) $st = 'pending'; $statusByIndex[$idx] = $st; $dateByIndex[$idx] = $row['stage_date'] ?: ($row['updated_at'] ?: ($row['created_at'] ?? null)); } // If no explicit "current", highlight the *last complete* stage $hasCurrent = false; foreach ($statusByIndex as $st) { if (strpos($st, 'current') !== false) { $hasCurrent = true; break; } } if (!$hasCurrent) { $lastComplete = -1; for ($i = 0; $i < $N; $i++) if ($statusByIndex[$i] === 'complete') $lastComplete = $i; if ($lastComplete >= 0) { $statusByIndex[$lastComplete] = 'current'; // keep green, add highlight } else { $statusByIndex[0] = trim($statusByIndex[0] . ' current'); // nothing complete yet } } $fmt = function (?string $s): string { if (!$s) return ''; $t = strtotime($s); return $t ? date('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.