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'] ?? '';
$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 — also enforce .pdf extension regardless of original name
$orig = preg_replace('/[^\w\.\- ]+/', '_', (string)$names[$i]);
if (strtolower(pathinfo($orig, PATHINFO_EXTENSION)) !== 'pdf') continue;
$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 '';
}
// 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.