Implement bulk export of client answers for coaches and enhance database handling in db_init.php
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-04 22:05:36 +02:00
parent 48a619ee4b
commit 6883654d7c
5 changed files with 330 additions and 0 deletions

View File

@ -197,6 +197,11 @@ function qdb_acquire_lock() {
* qdb_save() or qdb_discard() afterwards. * qdb_save() or qdb_discard() afterwards.
*/ */
function qdb_open(bool $writable = false): array { 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; $dbPath = QDB_PATH;
if (!is_dir(dirname($dbPath))) { if (!is_dir(dirname($dbPath))) {
@ -281,6 +286,12 @@ function qdb_open(bool $writable = false): array {
*/ */
function qdb_save(string $tmpDb, $lockFp): void { function qdb_save(string $tmpDb, $lockFp): void {
$masterKey = get_master_key_bytes(); $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); $plainDb = file_get_contents($tmpDb);
if ($plainDb === false) throw new Exception("Could not read temp DB for save"); if ($plainDb === false) throw new Exception("Could not read temp DB for save");
$enc = aes256_cbc_encrypt_bytes($plainDb, $masterKey); $enc = aes256_cbc_encrypt_bytes($plainDb, $masterKey);
@ -302,12 +313,18 @@ function qdb_save(string $tmpDb, $lockFp): void {
flock($lockFp, LOCK_UN); flock($lockFp, LOCK_UN);
fclose($lockFp); 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. * Discard changes -- clean up temp file and release lock without saving.
*/ */
function qdb_discard(string $tmpDb, $lockFp): void { function qdb_discard(string $tmpDb, $lockFp): void {
if (defined('QDB_READONLY_CACHE_HANDLE') && $tmpDb === QDB_READONLY_CACHE_HANDLE) {
return;
}
@unlink($tmpDb); @unlink($tmpDb);
if ($lockFp) { if ($lockFp) {
@flock($lockFp, LOCK_UN); @flock($lockFp, LOCK_UN);

View File

@ -6,6 +6,7 @@
* GET ?translations=1 -> global app UI strings only (not questionnaire content); **no auth** (login screen). * 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 ?clients=1 -> list of clients assigned to the authenticated coach
* GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted) * 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). * POST -> submit interview answers for a client (coach / supervisor / admin).
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit). * 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). * Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
@ -269,8 +270,17 @@ $qnID = $_GET['id'] ?? '';
$translations = $_GET['translations'] ?? ''; $translations = $_GET['translations'] ?? '';
$fetchClients = $_GET['clients'] ?? ''; $fetchClients = $_GET['clients'] ?? '';
$fetchAnswers = $_GET['answers'] ?? ''; $fetchAnswers = $_GET['answers'] ?? '';
$fetchAnswersBulk = $_GET['answersBulk'] ?? '';
$clientCode = trim($_GET['clientCode'] ?? ''); $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) { if ($fetchAnswers) {
require_role(['admin', 'supervisor', 'coach'], $tokenRec); require_role(['admin', 'supervisor', 'coach'], $tokenRec);
if ($clientCode === '') { if ($clientCode === '') {

View File

@ -255,3 +255,30 @@ function qdb_export_app_client_answers_bundle(PDO $pdo, string $clientCode): arr
} }
return $items; return $items;
} }
/**
* Bulk export: all coach-assigned clients with full answer bundles (caller applies RBAC).
*
* @return list<array{clientCode: string, questionnaires: list<array<string, mixed>>}>
*/
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;
}

225
lib/read_db_cache.php Normal file
View File

@ -0,0 +1,225 @@
<?php
/**
* Per-worker read-only DB snapshot in RAM (memfd on Linux) — no plaintext on durable disk.
*/
/** Passed as $tmpDb when the PDO comes from the worker cache (qdb_discard is a no-op). */
define('QDB_READONLY_CACHE_HANDLE', "\0qdb_cached_read");
define('QDB_READ_GENERATION_FILE', QDB_UPLOADS_DIR . '/.qdb_read_generation');
/**
* @return array{0: PDO, 1: string, 2: null}
*/
function qdb_read_db_open(): array {
$signature = qdb_read_cache_signature();
$cached = qdb_read_cache_get($signature);
if ($cached !== null) {
return [$cached, QDB_READONLY_CACHE_HANDLE, null];
}
[$pdo, $memfdPath, $memfdFd] = qdb_read_materialize_decrypted_db();
qdb_read_cache_store($signature, $pdo, $memfdPath, $memfdFd);
return [$pdo, QDB_READONLY_CACHE_HANDLE, null];
}
function qdb_read_cache_invalidate(): void {
qdb_read_cache_reset();
qdb_read_generation_bump();
}
function qdb_read_cache_signature(): string {
$gen = qdb_read_generation_current();
if (!is_file(QDB_PATH)) {
return $gen . ':missing';
}
$mtime = @filemtime(QDB_PATH);
$size = @filesize(QDB_PATH);
return $gen . ':' . (int)$mtime . ':' . (int)$size;
}
function qdb_read_generation_current(): string {
if (!is_file(QDB_READ_GENERATION_FILE)) {
return '0';
}
$raw = @file_get_contents(QDB_READ_GENERATION_FILE);
return $raw === false || $raw === '' ? '0' : trim($raw);
}
function qdb_read_generation_bump(): void {
$dir = dirname(QDB_READ_GENERATION_FILE);
if (!is_dir($dir)) {
@mkdir($dir, 0750, true);
}
$next = (string)((int)qdb_read_generation_current() + 1);
@file_put_contents(QDB_READ_GENERATION_FILE, $next, LOCK_EX);
}
/**
* @return array{0: PDO, 1: string, 2: int} memfdPath empty when using disk fallback
*/
function qdb_read_materialize_decrypted_db(): array {
$dbPath = QDB_PATH;
$masterKey = get_master_key_bytes();
$sql = file_get_contents(QDB_SCHEMA);
if ($sql === false) {
throw new Exception('Could not read schema.sql');
}
if (!file_exists($dbPath) || !is_file($dbPath)) {
$mem = qdb_read_open_sqlite_on_path(':memory:', $sql, true);
return [$mem, '', -1];
}
$storedEnc = @file_get_contents($dbPath);
if ($storedEnc === false) {
throw new Exception('Could not read stored DB');
}
$decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey);
$memfd = qdb_memfd_write($decrypted);
if ($memfd !== null) {
[$fd, $path] = $memfd;
$pdo = qdb_read_open_sqlite_on_path($path, $sql, false);
return [$pdo, $path, $fd];
}
$fallbackDir = QDB_UPLOADS_DIR . '/.private_read';
if (!is_dir($fallbackDir)) {
@mkdir($fallbackDir, 0700, true);
}
$fallback = $fallbackDir . '/snap_' . getmypid() . '_' . bin2hex(random_bytes(4)) . '.sqlite';
if (file_put_contents($fallback, $decrypted) === false) {
throw new Exception('Could not write private read snapshot');
}
@chmod($fallback, 0600);
$pdo = qdb_read_open_sqlite_on_path($fallback, $sql, false);
return [$pdo, $fallback, -1];
}
/**
* @return array{0: int, 1: string}|null [fd, /proc/self/fd/N]
*/
function qdb_memfd_write(string $bytes): ?array {
if (PHP_OS_FAMILY !== 'Linux') {
return null;
}
if (!extension_loaded('ffi')) {
return null;
}
static $ffi = null;
try {
if ($ffi === null) {
$ffi = FFI::cdef(
'int memfd_create(const char *name, unsigned int flags);',
'libc.so.6'
);
}
$MFD_CLOEXEC = 1;
$fd = (int)$ffi->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;
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class AppQuestionnairesBulkTest extends QdbTestCase
{
public function testCoachLoadsBulkAnswers(): void
{
$this->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);
}
}