*/ private array $app; public function __construct() { $this->app = Bootstrap::config('app'); } public function emailKey(string $email): string { return hash('sha256', strtolower(trim($email))); } /** @return array|null */ public function getDraft(string $email): ?array { $path = $this->draftPath($email); if (!is_file($path)) { return null; } return $this->readJsonFile($path); } /** @return array|null */ public function getSubmissionByEmail(string $email): ?array { $path = $this->submissionPath($email); if (!is_file($path)) { return null; } return $this->readJsonFile($path); } public function hasSubmission(string $email): bool { return is_file($this->submissionPath($email)); } /** * @param array $draft * @return array */ public function saveDraft(string $email, array $draft): array { $now = date('c'); $expires = date('c', time() + ((int) ($this->app['retention']['draft_days'] ?? 14) * 86400)); $current = $this->getDraft($email) ?? []; $payload = [ 'email' => strtolower(trim($email)), 'application_key' => $this->emailKey($email), 'status' => 'draft', 'created_at' => $current['created_at'] ?? $now, 'updated_at' => $now, 'expires_at' => $expires, 'step' => $draft['step'] ?? ($current['step'] ?? 1), 'form_data' => array_merge((array) ($current['form_data'] ?? []), (array) ($draft['form_data'] ?? [])), 'uploads' => $this->mergeUploads((array) ($current['uploads'] ?? []), (array) ($draft['uploads'] ?? [])), ]; $this->writeJsonFile($this->draftPath($email), $payload); return $payload; } /** * Replaces the full draft payload for an email (no upload merge). * * @param array $draft * @return array */ public function replaceDraft(string $email, array $draft): array { $now = date('c'); $expires = date('c', time() + ((int) ($this->app['retention']['draft_days'] ?? 14) * 86400)); $current = $this->getDraft($email) ?? []; $payload = [ 'email' => strtolower(trim($email)), 'application_key' => $this->emailKey($email), 'status' => 'draft', 'created_at' => $current['created_at'] ?? $now, 'updated_at' => $now, 'expires_at' => $expires, 'step' => $draft['step'] ?? ($current['step'] ?? 1), 'form_data' => (array) ($draft['form_data'] ?? []), 'uploads' => (array) ($draft['uploads'] ?? []), ]; $this->writeJsonFile($this->draftPath($email), $payload); return $payload; } /** * @param array $submission * @return array */ public function saveSubmission(string $email, array $submission): array { $now = date('c'); $expires = date('c', time() + ((int) ($this->app['retention']['submission_days'] ?? 90) * 86400)); $draft = $this->getDraft($email) ?? []; $payload = [ 'email' => strtolower(trim($email)), 'application_key' => $this->emailKey($email), 'status' => 'submitted', 'created_at' => $draft['created_at'] ?? $now, 'updated_at' => $now, 'submitted_at' => $now, 'expires_at' => $expires, 'step' => $submission['step'] ?? ($draft['step'] ?? null), 'form_data' => (array) ($submission['form_data'] ?? []), 'uploads' => (array) ($submission['uploads'] ?? []), ]; $this->writeJsonFile($this->submissionPath($email), $payload); $this->deleteDraft($email); return $payload; } public function deleteDraft(string $email): void { $path = $this->draftPath($email); if (is_file($path)) { unlink($path); } } /** @return array|null */ public function getSubmissionByKey(string $applicationKey): ?array { $safeKey = $this->normalizeApplicationKey($applicationKey); if ($safeKey === null) { return null; } $path = rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $safeKey . '.json'; if (!is_file($path)) { return null; } return $this->readJsonFile($path); } /** @return array> */ public function listSubmissions(): array { $dir = (string) $this->app['storage']['submissions']; $files = glob($dir . '/*.json') ?: []; $list = []; foreach ($files as $file) { $item = $this->readJsonFile($file); if (!is_array($item)) { continue; } $list[] = $item; } usort( $list, static fn (array $a, array $b): int => strcmp((string) ($b['submitted_at'] ?? ''), (string) ($a['submitted_at'] ?? '')) ); return $list; } public function deleteSubmissionByKey(string $applicationKey): void { $safeKey = $this->normalizeApplicationKey($applicationKey); if ($safeKey === null) { return; } $submissionPath = rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $safeKey . '.json'; if (is_file($submissionPath)) { $submission = $this->readJsonFile($submissionPath); if (isset($submission['email']) && is_string($submission['email'])) { $this->deleteDraft($submission['email']); } unlink($submissionPath); } } /** * @template T * @param callable():T $callback * @return T */ public function withEmailLock(string $email, callable $callback): mixed { $locksDir = (string) $this->app['storage']['locks']; if (!is_dir($locksDir)) { mkdir($locksDir, 0775, true); } $lockFile = $locksDir . '/' . $this->emailKey($email) . '.lock'; $handle = fopen($lockFile, 'c+'); if ($handle === false) { throw new RuntimeException('Could not open lock file.'); } try { if (!flock($handle, LOCK_EX)) { throw new RuntimeException('Could not acquire lock.'); } return $callback(); } finally { flock($handle, LOCK_UN); fclose($handle); } } private function draftPath(string $email): string { return rtrim((string) $this->app['storage']['drafts'], '/') . '/' . $this->emailKey($email) . '.json'; } private function submissionPath(string $email): string { return rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $this->emailKey($email) . '.json'; } /** @return array */ private function readJsonFile(string $path): array { $raw = file_get_contents($path); if ($raw === false || $raw === '') { return []; } $decoded = json_decode($raw, true); if (!is_array($decoded)) { return []; } return $decoded; } /** @param array $data */ private function writeJsonFile(string $path, array $data): void { $tmpPath = $path . '.tmp'; file_put_contents($tmpPath, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); rename($tmpPath, $path); } /** * @param array $existing * @param array $incoming * @return array */ private function mergeUploads(array $existing, array $incoming): array { foreach ($incoming as $field => $files) { if (!is_array($files)) { continue; } $existing[$field] = array_values(array_merge((array) ($existing[$field] ?? []), $files)); } return $existing; } private function normalizeApplicationKey(string $key): ?string { $key = strtolower(trim($key)); if (!preg_match('/^[a-f0-9]{64}$/', $key)) { return null; } return $key; } }