diff --git a/db_init.php b/db_init.php index eaed6ba..55ccb80 100644 --- a/db_init.php +++ b/db_init.php @@ -197,6 +197,11 @@ function qdb_acquire_lock() { * qdb_save() or qdb_discard() afterwards. */ function qdb_open(bool $writable = false): array { + if (!$writable) { + require_once __DIR__ . '/lib/read_db_cache.php'; + return qdb_read_db_open(); + } + $dbPath = QDB_PATH; if (!is_dir(dirname($dbPath))) { @@ -281,6 +286,12 @@ function qdb_open(bool $writable = false): array { */ function qdb_save(string $tmpDb, $lockFp): void { $masterKey = get_master_key_bytes(); + try { + $ck = new PDO('sqlite:' . $tmpDb); + $ck->exec('PRAGMA wal_checkpoint(FULL);'); + } catch (Throwable $e) { + // best-effort before reading plaintext bytes + } $plainDb = file_get_contents($tmpDb); if ($plainDb === false) throw new Exception("Could not read temp DB for save"); $enc = aes256_cbc_encrypt_bytes($plainDb, $masterKey); @@ -302,12 +313,18 @@ function qdb_save(string $tmpDb, $lockFp): void { flock($lockFp, LOCK_UN); fclose($lockFp); } + + require_once __DIR__ . '/lib/read_db_cache.php'; + qdb_read_cache_invalidate(); } /** * Discard changes -- clean up temp file and release lock without saving. */ function qdb_discard(string $tmpDb, $lockFp): void { + if (defined('QDB_READONLY_CACHE_HANDLE') && $tmpDb === QDB_READONLY_CACHE_HANDLE) { + return; + } @unlink($tmpDb); if ($lockFp) { @flock($lockFp, LOCK_UN); diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index 381b68a..3196cc3 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -6,6 +6,7 @@ * GET ?translations=1 -> global app UI strings only (not questionnaire content); **no auth** (login screen). * GET ?clients=1 -> list of clients assigned to the authenticated coach * GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted) + * GET ?answersBulk=1 -> all assigned clients' answers in one payload (coach, encrypted) * POST -> submit interview answers for a client (coach / supervisor / admin). * Re-posting the same clientCode + questionnaireID updates answers in place (re-edit). * Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token). @@ -269,8 +270,17 @@ $qnID = $_GET['id'] ?? ''; $translations = $_GET['translations'] ?? ''; $fetchClients = $_GET['clients'] ?? ''; $fetchAnswers = $_GET['answers'] ?? ''; +$fetchAnswersBulk = $_GET['answersBulk'] ?? ''; $clientCode = trim($_GET['clientCode'] ?? ''); +if ($fetchAnswersBulk) { + require_role(['coach'], $tokenRec); + require_once __DIR__ . '/../lib/app_answers.php'; + $clients = qdb_export_app_bulk_answers_for_coach($pdo, $tokenRec); + qdb_discard($tmpDb, $lockFp); + json_success_sensitive(['clients' => $clients], $tokenRec['_token']); +} + if ($fetchAnswers) { require_role(['admin', 'supervisor', 'coach'], $tokenRec); if ($clientCode === '') { diff --git a/lib/app_answers.php b/lib/app_answers.php index 4539c18..b698b66 100644 --- a/lib/app_answers.php +++ b/lib/app_answers.php @@ -255,3 +255,30 @@ function qdb_export_app_client_answers_bundle(PDO $pdo, string $clientCode): arr } return $items; } + +/** + * Bulk export: all coach-assigned clients with full answer bundles (caller applies RBAC). + * + * @return list>}> + */ +function qdb_export_app_bulk_answers_for_coach(PDO $pdo, array $tokenRec): array { + if (($tokenRec['role'] ?? '') !== 'coach') { + return []; + } + $coachID = trim((string)($tokenRec['entityID'] ?? '')); + if ($coachID === '') { + return []; + } + $stmt = $pdo->prepare( + 'SELECT clientCode FROM client WHERE coachID = :cid ORDER BY clientCode' + ); + $stmt->execute([':cid' => $coachID]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $clientCode) { + $out[] = [ + 'clientCode' => $clientCode, + 'questionnaires' => qdb_export_app_client_answers_bundle($pdo, (string)$clientCode), + ]; + } + return $out; +} diff --git a/lib/read_db_cache.php b/lib/read_db_cache.php new file mode 100644 index 0000000..9732acb --- /dev/null +++ b/lib/read_db_cache.php @@ -0,0 +1,225 @@ +memfd_create('qdb_read', $MFD_CLOEXEC); + if ($fd < 0) { + return null; + } + $written = @file_put_contents('/proc/self/fd/' . $fd, $bytes); + if ($written === false || $written !== strlen($bytes)) { + @close($fd); + return null; + } + return [$fd, '/proc/self/fd/' . $fd]; + } catch (Throwable $e) { + error_log('qdb_memfd_write: ' . $e->getMessage()); + return null; + } +} + +function qdb_read_open_sqlite_on_path(string $sqlitePath, string $schemaSql, bool $fresh): PDO { + if ($fresh) { + $pdo = new PDO('sqlite:' . $sqlitePath); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec($schemaSql); + $pdo->exec('PRAGMA user_version = ' . QDB_VERSION . ';'); + } else { + $pdo = new PDO('sqlite:' . $sqlitePath); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + } + $pdo->exec('PRAGMA foreign_keys = ON;'); + // DELETE (not WAL) so a migration persist via file_get_contents + qdb_save is complete. + $pdo->exec('PRAGMA journal_mode = DELETE;'); + + $schemaChanged = qdb_apply_migrations($pdo, $schemaSql); + if ($schemaChanged) { + $migrateLock = qdb_acquire_lock(); + try { + if ($sqlitePath === ':memory:') { + throw new Exception('Cannot persist migration from in-memory read snapshot'); + } + $savePath = $sqlitePath; + if (str_starts_with($sqlitePath, '/proc/')) { + $savePath = tempnam(sys_get_temp_dir(), 'qdb_migrate_'); + $bytes = file_get_contents($sqlitePath); + if ($bytes === false || file_put_contents($savePath, $bytes) === false) { + throw new Exception('Could not copy memfd DB for migration save'); + } + } + $pdo->exec('PRAGMA wal_checkpoint(FULL);'); + qdb_save($savePath, $migrateLock); + if ($savePath !== $sqlitePath && is_file($savePath)) { + @unlink($savePath); + } + } catch (Throwable $e) { + flock($migrateLock, LOCK_UN); + fclose($migrateLock); + throw $e; + } + return qdb_read_db_open()[0]; + } + + return $pdo; +} + +/** + * @return PDO|null + */ +function qdb_read_cache_get(string $signature) { + $state = &qdb_read_cache_state(); + if ($state['signature'] === $signature && $state['pdo'] instanceof PDO) { + return $state['pdo']; + } + return null; +} + +function qdb_read_cache_store(string $signature, PDO $pdo, string $memfdPath, int $memfdFd): void { + qdb_read_cache_reset(); + $state = &qdb_read_cache_state(); + $state['signature'] = $signature; + $state['pdo'] = $pdo; + $state['memfdPath'] = $memfdPath; + $state['memfdFd'] = $memfdFd; +} + +function qdb_read_cache_reset(): void { + $state = &qdb_read_cache_state(); + $state['signature'] = ''; + $state['pdo'] = null; + if ($state['memfdFd'] >= 0) { + @close($state['memfdFd']); + } + $state['memfdFd'] = -1; + if ($state['memfdPath'] !== '' && is_file($state['memfdPath'])) { + @unlink($state['memfdPath']); + } + $state['memfdPath'] = ''; +} + +/** + * @return array{signature: string, pdo: ?PDO, memfdPath: string, memfdFd: int} + */ +function &qdb_read_cache_state(): array { + static $state = [ + 'signature' => '', + 'pdo' => null, + 'memfdPath' => '', + 'memfdFd' => -1, + ]; + return $state; +} diff --git a/tests/Integration/AppQuestionnairesBulkTest.php b/tests/Integration/AppQuestionnairesBulkTest.php new file mode 100644 index 0000000..d69691a --- /dev/null +++ b/tests/Integration/AppQuestionnairesBulkTest.php @@ -0,0 +1,51 @@ +submitFixtureQuestionnaire(); + $login = $this->api()->loginMobileAndGetToken( + DatabaseSeeder::COACH_USERNAME, + DatabaseSeeder::PASSWORD + ); + $res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [ + 'answersBulk' => '1', + ]); + $this->assertApiOk($res); + $this->assertTrue($res['data']['encrypted'] ?? false); + $plain = qdb_decrypt_sensitive_envelope($res['data'], $login['token']); + $payload = json_decode($plain, true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($payload['clients'] ?? null); + $found = null; + foreach ($payload['clients'] as $row) { + if (($row['clientCode'] ?? '') === $this->fixture()->clientCode) { + $found = $row; + break; + } + } + $this->assertNotNull($found, 'fixture client missing from bulk payload'); + $this->assertNotEmpty($found['questionnaires'] ?? []); + $qn = $found['questionnaires'][0]; + $this->assertSame($this->fixture()->questionnaireId, $qn['questionnaireID'] ?? null); + $this->assertNotEmpty($qn['answers'] ?? []); + } + + public function testReadDbOpenUsesCacheOnSecondCall(): void + { + [$pdo1, $tmp1] = qdb_open(false); + [$pdo2, $tmp2] = qdb_open(false); + $this->assertSame(QDB_READONLY_CACHE_HANDLE, $tmp1); + $this->assertSame(QDB_READONLY_CACHE_HANDLE, $tmp2); + $this->assertSame($pdo1, $pdo2); + qdb_discard($tmp1, null); + qdb_discard($tmp2, null); + } +}