2061 lines
74 KiB
PHP
2061 lines
74 KiB
PHP
<?php
|
|
// /var/www/html/common.php
|
|
// Gemeinsame Helfer für Token-Validierung, Key-Derivation (HKDF) und AES (CBC, IV vorangestellt).
|
|
|
|
require_once __DIR__ . '/lib/text_sanitize.php';
|
|
|
|
/**
|
|
* Values loaded from the project .env file (see qdb_load_dotenv).
|
|
* Checked before getenv so a stale/wrong FPM pool variable cannot override .env.
|
|
*
|
|
* @var array<string, string>
|
|
*/
|
|
$GLOBALS['qdb_dotenv'] = [];
|
|
|
|
/**
|
|
* Read an environment variable (.env file first, then getenv / $_ENV / $_SERVER).
|
|
* PHP-FPM often disables putenv(); .env is applied via qdb_dotenv + $_ENV.
|
|
*/
|
|
function qdb_env_get(string $name): ?string {
|
|
if (isset($GLOBALS['qdb_dotenv'][$name]) && $GLOBALS['qdb_dotenv'][$name] !== '') {
|
|
return $GLOBALS['qdb_dotenv'][$name];
|
|
}
|
|
$v = getenv($name);
|
|
if ($v !== false && $v !== '') {
|
|
return $v;
|
|
}
|
|
if (isset($_ENV[$name]) && $_ENV[$name] !== '') {
|
|
return (string)$_ENV[$name];
|
|
}
|
|
if (isset($_SERVER[$name]) && $_SERVER[$name] !== '') {
|
|
return (string)$_SERVER[$name];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Set an environment variable for the current request (best-effort putenv). */
|
|
function qdb_env_set(string $name, string $value): void {
|
|
$_ENV[$name] = $value;
|
|
$_SERVER[$name] = $value;
|
|
if (function_exists('putenv')) {
|
|
@putenv("$name=$value");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load KEY=value pairs from .env in the project root.
|
|
* Values from the file take precedence over PHP-FPM / getenv for the same keys.
|
|
*/
|
|
function qdb_load_dotenv(string $path): void {
|
|
if (!is_readable($path)) {
|
|
return;
|
|
}
|
|
$lines = file($path, FILE_IGNORE_NEW_LINES);
|
|
if ($lines === false) {
|
|
return;
|
|
}
|
|
foreach ($lines as $line) {
|
|
$line = trim($line);
|
|
if ($line === '' || $line[0] === '#') {
|
|
continue;
|
|
}
|
|
$eq = strpos($line, '=');
|
|
if ($eq === false) {
|
|
continue;
|
|
}
|
|
$name = trim(substr($line, 0, $eq));
|
|
$value = trim(substr($line, $eq + 1));
|
|
if ($name === '') {
|
|
continue;
|
|
}
|
|
$len = strlen($value);
|
|
if ($len >= 2) {
|
|
$q = $value[0];
|
|
if (($q === '"' && $value[$len - 1] === '"') || ($q === "'" && $value[$len - 1] === "'")) {
|
|
$value = substr($value, 1, -1);
|
|
}
|
|
}
|
|
$GLOBALS['qdb_dotenv'][$name] = $value;
|
|
qdb_env_set($name, $value);
|
|
}
|
|
}
|
|
|
|
qdb_load_dotenv(__DIR__ . '/.env');
|
|
|
|
// --- MASTER-KEY (Server-seitig zum Speichern der DB) ---
|
|
// Per ENV 'QDB_MASTER_KEY' (Base64- oder ASCII-String) oder Fallback auf bisherigen Wert.
|
|
function get_master_key_bytes(): string {
|
|
$env = qdb_env_get('QDB_MASTER_KEY');
|
|
if ($env === null) {
|
|
throw new RuntimeException('QDB_MASTER_KEY environment variable is not set. Cannot proceed without a master key.');
|
|
}
|
|
$b = base64_decode($env, true);
|
|
if ($b !== false && strlen($b) > 0) {
|
|
return str_pad(substr($b, 0, 32), 32, "\0");
|
|
}
|
|
return str_pad(substr($env, 0, 32), 32, "\0");
|
|
}
|
|
|
|
// --- HKDF (SHA-256) über Token -> Session-Key (32 Byte) ---
|
|
// Kompatibel zu Android-Implementierung: salt = leer, info = "qdb-aes"
|
|
function hkdf_session_key_from_token(string $tokenHex, string $info = 'qdb-aes', int $len = 32): string {
|
|
$ikm = @hex2bin(trim($tokenHex));
|
|
if ($ikm === false) $ikm = $tokenHex; // Falls bereits binär
|
|
if (function_exists('hash_hkdf')) {
|
|
return hash_hkdf('sha256', $ikm, $len, $info, '');
|
|
}
|
|
// Fallback: eigene HKDF-Implementierung
|
|
$hashLen = 32;
|
|
$salt = str_repeat("\0", $hashLen);
|
|
$prk = hash_hmac('sha256', $ikm, $salt, true);
|
|
$okm = '';
|
|
$t = '';
|
|
$n = (int)ceil($len / $hashLen);
|
|
for ($i = 1; $i <= $n; $i++) {
|
|
$t = hash_hmac('sha256', $t . $info . chr($i), $prk, true);
|
|
$okm .= $t;
|
|
}
|
|
return substr($okm, 0, $len);
|
|
}
|
|
|
|
// --- Token-Storage (DB-backed via session table) ---
|
|
|
|
function token_add(string $token, int $ttlSeconds = 86400, array $extra = []): void {
|
|
require_once __DIR__ . '/db_init.php';
|
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
|
$now = time();
|
|
$pdo->prepare(
|
|
"INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp)
|
|
VALUES (:t, :uid, :role, :eid, :ca, :ea, :tmp)"
|
|
)->execute([
|
|
':t' => $token,
|
|
':uid' => $extra['userID'] ?? '',
|
|
':role' => $extra['role'] ?? '',
|
|
':eid' => $extra['entityID'] ?? '',
|
|
':ca' => $now,
|
|
':ea' => $now + $ttlSeconds,
|
|
':tmp' => (int)(!empty($extra['temp'])),
|
|
]);
|
|
// Opportunistic cleanup: remove expired sessions
|
|
$pdo->prepare("DELETE FROM session WHERE expiresAt < :now")->execute([':now' => $now]);
|
|
qdb_save($tmpDb, $lockFp);
|
|
}
|
|
|
|
function token_get_record(string $token): ?array {
|
|
require_once __DIR__ . '/db_init.php';
|
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
|
$stmt = $pdo->prepare("SELECT * FROM session WHERE token = :t AND expiresAt >= :now");
|
|
$stmt->execute([':t' => $token, ':now' => time()]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
qdb_discard($tmpDb, $lockFp);
|
|
if (!$row) return null;
|
|
// Map DB columns to the keys the rest of the codebase expects
|
|
return [
|
|
'token' => $row['token'],
|
|
'role' => $row['role'],
|
|
'entityID' => $row['entityID'],
|
|
'userID' => $row['userID'],
|
|
'created' => (int)$row['createdAt'],
|
|
'exp' => (int)$row['expiresAt'],
|
|
'temp' => (int)$row['temp'],
|
|
];
|
|
}
|
|
|
|
function token_revoke(string $token): void {
|
|
require_once __DIR__ . '/db_init.php';
|
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
|
$pdo->prepare("DELETE FROM session WHERE token = :t")->execute([':t' => $token]);
|
|
qdb_save($tmpDb, $lockFp);
|
|
}
|
|
|
|
// --- Auth helpers for endpoints ---
|
|
function get_bearer_token(): ?string {
|
|
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? '');
|
|
if (stripos($auth, 'Bearer ') === 0) {
|
|
return substr($auth, 7);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function require_valid_token(): array {
|
|
$token = get_bearer_token();
|
|
if (!$token) {
|
|
http_response_code(401);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode(["error" => "Missing Bearer token"]);
|
|
exit;
|
|
}
|
|
$rec = token_get_record($token);
|
|
if (!$rec) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode(["error" => "Invalid or expired token"]);
|
|
exit;
|
|
}
|
|
if (!empty($rec['temp'])) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode(["error" => "Password change required before access"]);
|
|
exit;
|
|
}
|
|
$rec['_token'] = $token;
|
|
return $rec;
|
|
}
|
|
|
|
function require_role(array $allowed, array $tokenRecord): void {
|
|
$role = $tokenRecord['role'] ?? '';
|
|
if (!in_array($role, $allowed, true)) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode(["error" => "Insufficient permissions"]);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
/** Roles that may use the management website (not the mobile app). */
|
|
function qdb_web_roles(): array {
|
|
return ['admin', 'supervisor'];
|
|
}
|
|
|
|
/** True when the request originates from the management website (see website/js/api.js). */
|
|
function qdb_is_web_client_request(): bool {
|
|
$h = $_SERVER['HTTP_X_QDB_CLIENT'] ?? '';
|
|
return strtolower(trim($h)) === 'web';
|
|
}
|
|
|
|
/** Reject coach sign-in from the website; coaches use the mobile app only. */
|
|
function qdb_reject_coach_web_login(string $role): void {
|
|
if ($role === 'coach' && qdb_is_web_client_request()) {
|
|
json_error(
|
|
'FORBIDDEN',
|
|
'Coach accounts can only sign in through the mobile app.',
|
|
403
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Valid bearer token plus admin/supervisor role (website API). */
|
|
function require_valid_token_web(): array {
|
|
$rec = require_valid_token();
|
|
require_role(qdb_web_roles(), $rec);
|
|
return $rec;
|
|
}
|
|
|
|
/**
|
|
* Build a SQL WHERE clause fragment that restricts client visibility by role.
|
|
* Returns [string $clause, array $params].
|
|
* $clientAlias is the table alias or name that has a coachID column (e.g. 'client').
|
|
*/
|
|
function rbac_client_filter(array $tokenRecord, string $clientAlias = 'client'): array {
|
|
$role = $tokenRecord['role'] ?? '';
|
|
$entityID = $tokenRecord['entityID'] ?? '';
|
|
switch ($role) {
|
|
case 'admin':
|
|
return ['1=1', []];
|
|
case 'supervisor':
|
|
return [
|
|
"$clientAlias.coachID IN (SELECT coachID FROM coach WHERE supervisorID = :rbac_eid)",
|
|
[':rbac_eid' => $entityID]
|
|
];
|
|
case 'coach':
|
|
return [
|
|
"$clientAlias.coachID = :rbac_eid",
|
|
[':rbac_eid' => $entityID]
|
|
];
|
|
default:
|
|
return ['0=1', []];
|
|
}
|
|
}
|
|
|
|
// --- Translations: German (de) is the canonical source language ---
|
|
define('QDB_SOURCE_LANGUAGE', 'de');
|
|
|
|
function qdb_ensure_source_language(PDO $pdo): void {
|
|
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
|
|
ON CONFLICT(languageCode) DO NOTHING")
|
|
->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']);
|
|
}
|
|
|
|
function qdb_upsert_source_translation(PDO $pdo, string $type, string $id, string $text): void {
|
|
qdb_put_translation($pdo, $type, $id, QDB_SOURCE_LANGUAGE, $text);
|
|
}
|
|
|
|
/** Catalog of global app UI string keys (LanguageManager). */
|
|
function qdb_app_string_catalog(bool $reload = false): array {
|
|
static $catalog = null;
|
|
if (!$reload && $catalog !== null) {
|
|
return $catalog;
|
|
}
|
|
$path = qdb_app_string_catalog_path();
|
|
if (!is_readable($path)) {
|
|
$catalog = ['keys' => [], 'germanDefaults' => []];
|
|
return $catalog;
|
|
}
|
|
$data = json_decode(file_get_contents($path), true);
|
|
$catalog = is_array($data) ? $data : ['keys' => [], 'germanDefaults' => []];
|
|
return $catalog;
|
|
}
|
|
|
|
function qdb_app_string_catalog_path(): string {
|
|
return __DIR__ . '/data/app_ui_strings.json';
|
|
}
|
|
|
|
/** Remove questionnaire_group_* from catalog file (keys list + germanDefaults). */
|
|
function qdb_remove_questionnaire_group_from_catalog(string $stringKey): void {
|
|
if (!qdb_is_questionnaire_group_string_key($stringKey)) {
|
|
return;
|
|
}
|
|
$path = qdb_app_string_catalog_path();
|
|
if (!is_readable($path) || !is_writable($path)) {
|
|
return;
|
|
}
|
|
$data = json_decode(file_get_contents($path), true);
|
|
if (!is_array($data)) {
|
|
return;
|
|
}
|
|
$changed = false;
|
|
if (!empty($data['keys']) && is_array($data['keys'])) {
|
|
$next = array_values(array_filter(
|
|
$data['keys'],
|
|
fn($k) => (string)$k !== $stringKey
|
|
));
|
|
if (count($next) !== count($data['keys'])) {
|
|
$data['keys'] = $next;
|
|
$changed = true;
|
|
}
|
|
}
|
|
if (!empty($data['germanDefaults']) && is_array($data['germanDefaults']) && array_key_exists($stringKey, $data['germanDefaults'])) {
|
|
unset($data['germanDefaults'][$stringKey]);
|
|
$changed = true;
|
|
}
|
|
if (!$changed) {
|
|
return;
|
|
}
|
|
file_put_contents(
|
|
$path,
|
|
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n",
|
|
LOCK_EX
|
|
);
|
|
qdb_app_string_catalog(true);
|
|
}
|
|
|
|
function qdb_app_string_key_set(): array {
|
|
$keys = qdb_app_string_catalog()['keys'] ?? [];
|
|
return array_flip($keys);
|
|
}
|
|
|
|
/** Keys for dev-only UI (database export, etc.) — excluded from website App UI and app sync catalog. */
|
|
function qdb_dev_only_app_string_key_set(): array {
|
|
static $set = null;
|
|
if ($set !== null) {
|
|
return $set;
|
|
}
|
|
$set = array_flip([
|
|
'database',
|
|
'database_clients_title',
|
|
'download_header',
|
|
'export_success_downloads',
|
|
'export_failed',
|
|
'headers',
|
|
'view_missing',
|
|
'no_header_template_found',
|
|
'saved_pdf_csv',
|
|
'no_pdf_viewer',
|
|
'save_error',
|
|
'no_clients_available',
|
|
'questionnaire_id',
|
|
'questionnaires',
|
|
'status',
|
|
'data',
|
|
'data_final_warning',
|
|
'open_client_via_load',
|
|
'ask_before_upload',
|
|
'You have completed questionnaires that have not been uploaded yet.',
|
|
'You have completed questionnaires that have not been uploaded yet. Tap to open the app and upload.',
|
|
]);
|
|
return $set;
|
|
}
|
|
|
|
/** Prefix for app UI strings that label questionnaire category groups on the opening screen. */
|
|
function qdb_questionnaire_group_prefix(): string {
|
|
return 'questionnaire_group_';
|
|
}
|
|
|
|
function qdb_questionnaire_group_string_key(string $categoryKey): string {
|
|
$categoryKey = trim($categoryKey);
|
|
return $categoryKey === '' ? '' : qdb_questionnaire_group_prefix() . $categoryKey;
|
|
}
|
|
|
|
function qdb_is_questionnaire_group_string_key(string $stringKey): bool {
|
|
return str_starts_with($stringKey, qdb_questionnaire_group_prefix());
|
|
}
|
|
|
|
function qdb_category_key_from_group_string_key(string $stringKey): string {
|
|
$prefix = qdb_questionnaire_group_prefix();
|
|
return str_starts_with($stringKey, $prefix) ? substr($stringKey, strlen($prefix)) : '';
|
|
}
|
|
|
|
/** @return array<string, int> categoryKey => questionnaire count */
|
|
function qdb_category_keys_in_use(PDO $pdo): array {
|
|
$map = [];
|
|
$rows = $pdo->query(
|
|
"SELECT categoryKey, COUNT(*) AS c FROM questionnaire
|
|
WHERE trim(categoryKey) != '' GROUP BY categoryKey"
|
|
)->fetchAll(PDO::FETCH_ASSOC);
|
|
foreach ($rows as $row) {
|
|
$k = trim((string)($row['categoryKey'] ?? ''));
|
|
if ($k !== '') {
|
|
$map[$k] = (int)$row['c'];
|
|
}
|
|
}
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* App string entries for category keys in use (not already in the static JSON catalog keys list).
|
|
* @return list<array{key: string, displayKey: string, type: string, entityId: string, sortOrder: int, germanDefault: string, categoryKey: string}>
|
|
*/
|
|
function qdb_category_group_string_entries(PDO $pdo): array {
|
|
$catalog = qdb_app_string_catalog();
|
|
$defaults = $catalog['germanDefaults'] ?? [];
|
|
$staticKeys = qdb_app_string_key_set();
|
|
$entries = [];
|
|
$order = 0;
|
|
foreach (qdb_category_keys_in_use($pdo) as $catKey => $_count) {
|
|
$stringKey = qdb_questionnaire_group_string_key($catKey);
|
|
if ($stringKey === '' || isset($staticKeys[$stringKey])) {
|
|
continue;
|
|
}
|
|
$entries[] = [
|
|
'key' => $stringKey,
|
|
'displayKey' => $stringKey,
|
|
'type' => 'app_string',
|
|
'entityId' => $stringKey,
|
|
'sortOrder' => $order++,
|
|
'germanDefault' => $defaults[$stringKey] ?? $catKey,
|
|
'categoryKey' => $catKey,
|
|
];
|
|
}
|
|
usort($entries, fn($a, $b) => strcmp($a['categoryKey'], $b['categoryKey']));
|
|
return $entries;
|
|
}
|
|
|
|
/**
|
|
* Category keys with translations in DB but no questionnaire using them.
|
|
* @return list<string>
|
|
*/
|
|
function qdb_orphaned_category_keys(PDO $pdo): array {
|
|
$inUse = qdb_category_keys_in_use($pdo);
|
|
$prefix = qdb_questionnaire_group_prefix();
|
|
$orphans = [];
|
|
$stmt = $pdo->query(
|
|
"SELECT DISTINCT stringKey FROM string_translation WHERE stringKey LIKE " . $pdo->quote($prefix . '%')
|
|
);
|
|
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $stringKey) {
|
|
$catKey = qdb_category_key_from_group_string_key((string)$stringKey);
|
|
if ($catKey !== '' && !isset($inUse[$catKey])) {
|
|
$orphans[$catKey] = true;
|
|
}
|
|
}
|
|
ksort($orphans);
|
|
return array_keys($orphans);
|
|
}
|
|
|
|
/**
|
|
* @return list<array{categoryKey: string, stringKey: string, questionnaireCount: int, orphaned: bool, germanLabel: string}>
|
|
*/
|
|
function qdb_list_category_keys_for_dashboard(PDO $pdo): array {
|
|
$defaults = qdb_app_string_catalog()['germanDefaults'] ?? [];
|
|
$inUse = qdb_category_keys_in_use($pdo);
|
|
$list = [];
|
|
|
|
$loadGerman = function (string $stringKey) use ($pdo, $defaults): string {
|
|
$stmt = $pdo->prepare(
|
|
'SELECT text FROM string_translation WHERE stringKey = :k AND languageCode = :lang LIMIT 1'
|
|
);
|
|
$stmt->execute([':k' => $stringKey, ':lang' => QDB_SOURCE_LANGUAGE]);
|
|
$text = $stmt->fetchColumn();
|
|
if (is_string($text) && $text !== '') {
|
|
return $text;
|
|
}
|
|
return $defaults[$stringKey] ?? '';
|
|
};
|
|
|
|
foreach ($inUse as $catKey => $count) {
|
|
$stringKey = qdb_questionnaire_group_string_key($catKey);
|
|
$list[] = [
|
|
'categoryKey' => $catKey,
|
|
'stringKey' => $stringKey,
|
|
'questionnaireCount' => $count,
|
|
'orphaned' => false,
|
|
'germanLabel' => $loadGerman($stringKey) ?: $catKey,
|
|
];
|
|
}
|
|
|
|
foreach (qdb_orphaned_category_keys($pdo) as $catKey) {
|
|
$stringKey = qdb_questionnaire_group_string_key($catKey);
|
|
$list[] = [
|
|
'categoryKey' => $catKey,
|
|
'stringKey' => $stringKey,
|
|
'questionnaireCount' => 0,
|
|
'orphaned' => true,
|
|
'germanLabel' => $loadGerman($stringKey) ?: $catKey,
|
|
];
|
|
}
|
|
|
|
usort($list, fn($a, $b) => strcmp($a['categoryKey'], $b['categoryKey']));
|
|
return $list;
|
|
}
|
|
|
|
/** Persist default German for a questionnaire_group_* key in app_ui_strings.json. */
|
|
function qdb_update_questionnaire_group_german_default(string $stringKey, string $text): void {
|
|
if (!qdb_is_questionnaire_group_string_key($stringKey)) {
|
|
return;
|
|
}
|
|
$path = qdb_app_string_catalog_path();
|
|
if (!is_readable($path) || !is_writable($path)) {
|
|
return;
|
|
}
|
|
$data = json_decode(file_get_contents($path), true);
|
|
if (!is_array($data)) {
|
|
return;
|
|
}
|
|
if (!isset($data['germanDefaults']) || !is_array($data['germanDefaults'])) {
|
|
$data['germanDefaults'] = [];
|
|
}
|
|
if (($data['germanDefaults'][$stringKey] ?? '') === $text) {
|
|
return;
|
|
}
|
|
$data['germanDefaults'][$stringKey] = $text;
|
|
file_put_contents(
|
|
$path,
|
|
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n",
|
|
LOCK_EX
|
|
);
|
|
qdb_app_string_catalog(true);
|
|
}
|
|
|
|
/** Set German label for a category (app opening screen group title). */
|
|
function qdb_set_category_german_label(PDO $pdo, string $categoryKey, string $text): string {
|
|
$categoryKey = trim($categoryKey);
|
|
if ($categoryKey === '') {
|
|
json_error('INVALID_FIELD', 'categoryKey is required', 400);
|
|
}
|
|
$text = trim($text);
|
|
if ($text === '') {
|
|
json_error('INVALID_FIELD', 'German label is required', 400);
|
|
}
|
|
$stringKey = qdb_questionnaire_group_string_key($categoryKey);
|
|
qdb_set_app_string_german_label($pdo, $stringKey, $text);
|
|
qdb_update_questionnaire_group_german_default($stringKey, $text);
|
|
return $stringKey;
|
|
}
|
|
|
|
/**
|
|
* Remove a category key completely: clear questionnaires, translations, and catalog defaults.
|
|
* @return int questionnaires updated
|
|
*/
|
|
function qdb_delete_category_key(PDO $pdo, string $categoryKey): int {
|
|
$categoryKey = trim($categoryKey);
|
|
if ($categoryKey === '') {
|
|
json_error('INVALID_FIELD', 'categoryKey is required', 400);
|
|
}
|
|
$stringKey = qdb_questionnaire_group_string_key($categoryKey);
|
|
|
|
$stmt = $pdo->prepare('UPDATE questionnaire SET categoryKey = \'\' WHERE categoryKey = :ck');
|
|
$stmt->execute([':ck' => $categoryKey]);
|
|
$cleared = $stmt->rowCount();
|
|
|
|
$pdo->prepare('DELETE FROM string_translation WHERE stringKey = :k')
|
|
->execute([':k' => $stringKey]);
|
|
|
|
qdb_remove_questionnaire_group_from_catalog($stringKey);
|
|
|
|
return $cleared;
|
|
}
|
|
|
|
/**
|
|
* Global app UI strings (screens, toasts, auth, etc.) for the mobile app.
|
|
* @return list<array{key: string, type: string, entityId: string, sortOrder: int}>
|
|
*/
|
|
function qdb_app_ui_string_entries(): array {
|
|
$catalog = qdb_app_string_catalog();
|
|
$defaults = $catalog['germanDefaults'] ?? [];
|
|
$entries = [];
|
|
$order = 0;
|
|
foreach ($catalog['keys'] ?? [] as $key) {
|
|
if (qdb_is_questionnaire_group_string_key($key)) {
|
|
continue;
|
|
}
|
|
$entries[] = [
|
|
'key' => $key,
|
|
'displayKey' => $key,
|
|
'type' => 'app_string',
|
|
'entityId' => $key,
|
|
'sortOrder' => $order++,
|
|
'germanDefault' => $defaults[$key] ?? $key,
|
|
];
|
|
}
|
|
return $entries;
|
|
}
|
|
|
|
/**
|
|
* Static catalog plus questionnaire_group_* keys for categoryKey values in use.
|
|
* @return list<array{key: string, type: string, entityId: string, sortOrder: int}>
|
|
*/
|
|
/** messageKey field inside questionnaire conditionJson (app locked hint). */
|
|
function qdb_message_key_from_condition_json(?string $json): string {
|
|
if ($json === null || trim($json) === '' || trim($json) === '{}') {
|
|
return '';
|
|
}
|
|
$obj = json_decode($json, true);
|
|
if (!is_array($obj)) {
|
|
return '';
|
|
}
|
|
$key = trim((string)($obj['messageKey'] ?? ''));
|
|
return $key !== '' && qdb_is_stable_key($key) ? $key : '';
|
|
}
|
|
|
|
/** @return array<string, true> */
|
|
function qdb_collect_condition_message_keys(PDO $pdo): array {
|
|
$keys = [];
|
|
foreach ($pdo->query('SELECT conditionJson FROM questionnaire')->fetchAll(PDO::FETCH_COLUMN) as $json) {
|
|
$mk = qdb_message_key_from_condition_json($json);
|
|
if ($mk !== '') {
|
|
$keys[$mk] = true;
|
|
}
|
|
}
|
|
return $keys;
|
|
}
|
|
|
|
/**
|
|
* App string entries for condition messageKey values referenced in questionnaire conditions.
|
|
* @return list<array{key: string, displayKey: string, type: string, entityId: string, sortOrder: int, germanDefault: string}>
|
|
*/
|
|
function qdb_condition_message_string_entries(PDO $pdo): array {
|
|
$staticKeys = qdb_app_string_key_set();
|
|
$defaults = qdb_app_string_catalog()['germanDefaults'] ?? [];
|
|
$entries = [];
|
|
$order = 0;
|
|
foreach (array_keys(qdb_collect_condition_message_keys($pdo)) as $key) {
|
|
if (isset($staticKeys[$key])) {
|
|
continue;
|
|
}
|
|
$entries[] = [
|
|
'key' => $key,
|
|
'displayKey' => $key,
|
|
'type' => 'app_string',
|
|
'entityId' => $key,
|
|
'sortOrder' => $order++,
|
|
'germanDefault' => $defaults[$key] ?? $key,
|
|
];
|
|
}
|
|
usort($entries, fn($a, $b) => strcmp($a['key'], $b['key']));
|
|
return $entries;
|
|
}
|
|
|
|
function qdb_update_app_string_german_default(string $stringKey, string $text): void {
|
|
$path = qdb_app_string_catalog_path();
|
|
if (!is_readable($path) || !is_writable($path)) {
|
|
return;
|
|
}
|
|
$data = json_decode(file_get_contents($path), true);
|
|
if (!is_array($data)) {
|
|
return;
|
|
}
|
|
if (!isset($data['germanDefaults']) || !is_array($data['germanDefaults'])) {
|
|
$data['germanDefaults'] = [];
|
|
}
|
|
if (($data['germanDefaults'][$stringKey] ?? '') === $text) {
|
|
return;
|
|
}
|
|
$data['germanDefaults'][$stringKey] = $text;
|
|
file_put_contents(
|
|
$path,
|
|
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n",
|
|
LOCK_EX
|
|
);
|
|
qdb_app_string_catalog(true);
|
|
}
|
|
|
|
function qdb_set_app_string_german_label(PDO $pdo, string $stringKey, string $text): void {
|
|
$stringKey = qdb_validate_stable_key($stringKey, 'messageKey');
|
|
$text = trim($text);
|
|
if ($text === '') {
|
|
json_error('INVALID_FIELD', 'German label is required', 400);
|
|
}
|
|
qdb_put_translation($pdo, 'app_string', $stringKey, QDB_SOURCE_LANGUAGE, $text);
|
|
qdb_update_app_string_german_default($stringKey, $text);
|
|
}
|
|
|
|
function qdb_app_ui_string_entries_merged(PDO $pdo): array {
|
|
$merged = qdb_app_ui_string_entries();
|
|
$existing = [];
|
|
foreach ($merged as $e) {
|
|
$existing[$e['key']] = true;
|
|
}
|
|
$order = count($merged);
|
|
foreach (qdb_category_group_string_entries($pdo) as $e) {
|
|
if (!isset($existing[$e['key']])) {
|
|
$e['sortOrder'] = $order++;
|
|
$merged[] = $e;
|
|
$existing[$e['key']] = true;
|
|
}
|
|
}
|
|
foreach (qdb_condition_message_string_entries($pdo) as $e) {
|
|
if (!isset($existing[$e['key']])) {
|
|
$e['sortOrder'] = $order++;
|
|
$merged[] = $e;
|
|
$existing[$e['key']] = true;
|
|
}
|
|
}
|
|
return $merged;
|
|
}
|
|
|
|
/**
|
|
* All string keys referenced by questionnaire content (questions, options, config).
|
|
* @return array<string, true>
|
|
*/
|
|
function qdb_all_questionnaire_translation_key_set(PDO $pdo): array {
|
|
static $cache = null;
|
|
if ($cache !== null) {
|
|
return $cache;
|
|
}
|
|
|
|
$keys = [];
|
|
$rows = $pdo->query("SELECT defaultText, configJson FROM question")->fetchAll(PDO::FETCH_ASSOC);
|
|
foreach ($rows as $dbQ) {
|
|
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
|
$qk = qdb_question_key($config, $dbQ['defaultText']);
|
|
if ($qk !== '') {
|
|
$keys[$qk] = true;
|
|
if (!empty($config['noteBefore'])) {
|
|
$keys[qdb_note_before_key($qk)] = true;
|
|
}
|
|
if (!empty($config['noteAfter'])) {
|
|
$keys[qdb_note_after_key($qk)] = true;
|
|
}
|
|
} elseif ($dbQ['defaultText'] !== '') {
|
|
$keys[$dbQ['defaultText']] = true;
|
|
}
|
|
foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) {
|
|
if (!empty($config[$field])) {
|
|
$keys[$config[$field]] = true;
|
|
}
|
|
}
|
|
if (!empty($config['symptoms']) && is_array($config['symptoms'])) {
|
|
foreach ($config['symptoms'] as $s) {
|
|
if ($s !== '') {
|
|
$keys[$s] = true;
|
|
}
|
|
}
|
|
}
|
|
if (!empty($config['options']) && is_array($config['options'])) {
|
|
foreach ($config['options'] as $opt) {
|
|
if (is_string($opt) && $opt !== '') {
|
|
$keys[$opt] = true;
|
|
} elseif (is_array($opt) && !empty($opt['key'])) {
|
|
$keys[$opt['key']] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($pdo->query("SELECT defaultText FROM answer_option")->fetchAll(PDO::FETCH_COLUMN) as $text) {
|
|
if ($text !== '') {
|
|
$keys[$text] = true;
|
|
}
|
|
}
|
|
|
|
$cache = $keys;
|
|
return $cache;
|
|
}
|
|
|
|
/**
|
|
* App UI catalog for the website Translations page (coach-visible, no dev-only keys).
|
|
* Catalog keys always appear here even when also used inside a questionnaire (e.g. "save").
|
|
*/
|
|
function qdb_app_ui_string_entries_for_website(PDO $pdo): array {
|
|
$dev = qdb_dev_only_app_string_key_set();
|
|
$entries = [];
|
|
foreach (qdb_app_ui_string_entries_merged($pdo) as $e) {
|
|
if (!isset($dev[$e['key']])) {
|
|
$entries[] = $e;
|
|
}
|
|
}
|
|
return $entries;
|
|
}
|
|
|
|
/** Upsert one translation row; sync German text into defaultText for questions/options. */
|
|
function qdb_put_translation(PDO $pdo, string $type, string $id, string $lang, string $text): void {
|
|
if ($type === 'question') {
|
|
$pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text)
|
|
VALUES (:id, :lang, :t)
|
|
ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text")
|
|
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
|
if ($lang === QDB_SOURCE_LANGUAGE) {
|
|
$pdo->prepare("UPDATE question SET defaultText = :t WHERE questionID = :id")
|
|
->execute([':t' => $text, ':id' => $id]);
|
|
}
|
|
} elseif ($type === 'answer_option') {
|
|
$pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text)
|
|
VALUES (:id, :lang, :t)
|
|
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text")
|
|
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
|
// defaultText holds optionKey; German label lives only in answer_option_translation.
|
|
} elseif ($type === 'string' || $type === 'app_string') {
|
|
$pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text)
|
|
VALUES (:id, :lang, :t)
|
|
ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text")
|
|
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
|
}
|
|
}
|
|
|
|
/** Short label for translation UI (Key column); full text stays in `key` for API lookup. */
|
|
function qdb_translation_display_key(array $e): string {
|
|
return qdb_translation_row_label($e);
|
|
}
|
|
|
|
function qdb_short_entity_label(string $entityId): string {
|
|
$pos = strrpos($entityId, '__');
|
|
return $pos !== false ? substr($entityId, $pos + 2) : $entityId;
|
|
}
|
|
|
|
function qdb_require_non_empty_german(string $text, string $label = 'German text'): void {
|
|
if ($text === '') {
|
|
json_error('MISSING_FIELDS', "$label is required", 400);
|
|
}
|
|
}
|
|
|
|
/** Stable identifier: snake_case starting with a letter (case preserved for LM keys). */
|
|
function qdb_is_stable_key(string $s): bool {
|
|
return $s !== '' && (bool)preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $s);
|
|
}
|
|
|
|
/**
|
|
* Coerce an arbitrary key string into a stable identifier (letters, digits, underscores;
|
|
* must start with a letter). Used on import and API writes so bundles are not limited to
|
|
* pre-validated keys (e.g. numeric option labels "1", "42").
|
|
*/
|
|
function qdb_normalize_stable_key(string $s): string {
|
|
$s = trim($s);
|
|
if ($s === '') {
|
|
return '';
|
|
}
|
|
if (qdb_is_stable_key($s)) {
|
|
return $s;
|
|
}
|
|
$out = preg_replace('/[^a-zA-Z0-9_]+/', '_', $s);
|
|
$out = preg_replace('/_+/', '_', $out ?? '');
|
|
$out = trim($out, '_');
|
|
if ($out === '') {
|
|
return 'k_' . substr(hash('crc32b', $s), 0, 8);
|
|
}
|
|
if (!preg_match('/^[a-zA-Z]/', $out)) {
|
|
$out = 'k_' . $out;
|
|
}
|
|
return qdb_is_stable_key($out) ? $out : 'k_' . substr(hash('crc32b', $s), 0, 8);
|
|
}
|
|
|
|
function qdb_validate_stable_key(string $s, string $label = 'Key'): string {
|
|
$s = trim($s);
|
|
if ($s === '') {
|
|
json_error('INVALID_FIELD', "$label is required", 400);
|
|
}
|
|
$normalized = qdb_normalize_stable_key($s);
|
|
if (!qdb_is_stable_key($normalized)) {
|
|
json_error('INVALID_FIELD', "$label could not be normalized to a valid key", 400);
|
|
}
|
|
return $normalized;
|
|
}
|
|
|
|
/** Resolve app lookup key for a question row. */
|
|
function qdb_question_key(array $config, string $defaultText): string {
|
|
$key = trim((string)($config['questionKey'] ?? ''));
|
|
if ($key !== '' && qdb_is_stable_key($key)) {
|
|
return $key;
|
|
}
|
|
if (qdb_is_stable_key($defaultText)) {
|
|
return $defaultText;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/** Option key is stored in answer_option.defaultText. */
|
|
function qdb_option_key(array $aoRow): string {
|
|
$dt = trim((string)($aoRow['defaultText'] ?? ''));
|
|
return qdb_is_stable_key($dt) ? $dt : '';
|
|
}
|
|
|
|
/** Human-readable text for glass-scale answers stored as JSON on the parent question row. */
|
|
function qdb_format_glass_answer_json(?string $raw): string
|
|
{
|
|
if ($raw === null || trim($raw) === '') {
|
|
return '';
|
|
}
|
|
$decoded = json_decode($raw, true);
|
|
if (!is_array($decoded)) {
|
|
return $raw;
|
|
}
|
|
$parts = [];
|
|
foreach ($decoded as $key => $val) {
|
|
$val = trim((string)$val);
|
|
if ($val === '') {
|
|
continue;
|
|
}
|
|
$parts[] = $key . ': ' . $val;
|
|
}
|
|
return implode('; ', $parts);
|
|
}
|
|
|
|
/**
|
|
* Map glass symptom string keys to parent questionID for one questionnaire.
|
|
*
|
|
* @return array<string, string> symptomKey => parentQuestionID
|
|
*/
|
|
function qdb_glass_symptom_parent_map(PDO $pdo, string $qnID): array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT questionID, configJson FROM question WHERE questionnaireID = :qn AND type = 'glass_scale_question'"
|
|
);
|
|
$stmt->execute([':qn' => $qnID]);
|
|
$map = [];
|
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
|
$cfg = json_decode($row['configJson'] ?? '{}', true) ?: [];
|
|
foreach ($cfg['symptoms'] ?? [] as $symptomKey) {
|
|
$sk = trim((string)$symptomKey);
|
|
if ($sk !== '') {
|
|
$map[$sk] = $row['questionID'];
|
|
}
|
|
}
|
|
}
|
|
return $map;
|
|
}
|
|
|
|
/** Merge symptom => label selections into JSON for one glass-scale parent question. */
|
|
function qdb_merge_glass_symptom_json(?string $existingJson, array $symptomLabels): string
|
|
{
|
|
$data = [];
|
|
if ($existingJson !== null && trim($existingJson) !== '') {
|
|
$decoded = json_decode($existingJson, true);
|
|
if (is_array($decoded)) {
|
|
$data = $decoded;
|
|
}
|
|
}
|
|
foreach ($symptomLabels as $key => $label) {
|
|
$k = trim((string)$key);
|
|
$v = trim((string)$label);
|
|
if ($k !== '' && $v !== '') {
|
|
$data[$k] = $v;
|
|
}
|
|
}
|
|
return json_encode($data, JSON_UNESCAPED_UNICODE);
|
|
}
|
|
|
|
/** Single symptom value from glass-scale JSON stored on the parent question row. */
|
|
function qdb_glass_symptom_answer_value(?string $json, string $symptomKey): string
|
|
{
|
|
$symptomKey = trim($symptomKey);
|
|
if ($symptomKey === '' || $json === null || trim($json) === '') {
|
|
return '';
|
|
}
|
|
$decoded = json_decode($json, true);
|
|
if (!is_array($decoded)) {
|
|
return '';
|
|
}
|
|
return trim((string)($decoded[$symptomKey] ?? ''));
|
|
}
|
|
|
|
/**
|
|
* Flat columns for results / CSV: one column per glass-scale symptom.
|
|
*
|
|
* @return list<array{header: string, questionID: string, symptomKey: ?string, kind: string, question: array}>
|
|
*/
|
|
function qdb_results_export_columns(array $questions, string $qnID): array
|
|
{
|
|
$columns = [];
|
|
foreach ($questions as $q) {
|
|
$cfg = json_decode($q['configJson'] ?? '{}', true) ?: [];
|
|
$type = $q['type'] ?? '';
|
|
if ($type === 'glass_scale_question') {
|
|
$symptoms = $cfg['symptoms'] ?? [];
|
|
if (is_array($symptoms) && $symptoms !== []) {
|
|
foreach ($symptoms as $symptomKey) {
|
|
$sk = trim((string)$symptomKey);
|
|
if ($sk === '') {
|
|
continue;
|
|
}
|
|
$columns[] = [
|
|
'header' => $sk,
|
|
'questionID' => $q['questionID'],
|
|
'symptomKey' => $sk,
|
|
'kind' => 'glass_symptom',
|
|
'question' => $q,
|
|
];
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
$columns[] = [
|
|
'header' => qdb_question_column_label($cfg, $q['defaultText'], $q['questionID'], $qnID),
|
|
'questionID' => $q['questionID'],
|
|
'symptomKey' => null,
|
|
'kind' => 'question',
|
|
'question' => $q,
|
|
];
|
|
}
|
|
return $columns;
|
|
}
|
|
|
|
/** Cell value for one export/results column (including glass symptom columns). */
|
|
function qdb_results_column_cell_value(array $column, ?array $answerRow, array $optionTextMap): string
|
|
{
|
|
if (($column['kind'] ?? '') === 'glass_symptom') {
|
|
return qdb_glass_symptom_answer_value(
|
|
$answerRow['freeTextValue'] ?? null,
|
|
(string)($column['symptomKey'] ?? '')
|
|
);
|
|
}
|
|
return qdb_format_client_answer_display($column['question'], $answerRow, $optionTextMap);
|
|
}
|
|
|
|
/** Format one client_answer row for results table / CSV export. */
|
|
function qdb_format_client_answer_display(array $question, ?array $answerRow, array $optionTextMap): string
|
|
{
|
|
if (!$answerRow) {
|
|
return '';
|
|
}
|
|
if (($question['type'] ?? '') === 'glass_scale_question') {
|
|
return qdb_format_glass_answer_json($answerRow['freeTextValue'] ?? null);
|
|
}
|
|
$aoid = $answerRow['answerOptionID'] ?? null;
|
|
if ($aoid && isset($optionTextMap[$aoid])) {
|
|
return (string)$optionTextMap[$aoid];
|
|
}
|
|
if (!empty($answerRow['freeTextValue'])) {
|
|
return (string)$answerRow['freeTextValue'];
|
|
}
|
|
if (isset($answerRow['numericValue']) && $answerRow['numericValue'] !== null && $answerRow['numericValue'] !== '') {
|
|
return (string)$answerRow['numericValue'];
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function qdb_note_before_key(string $questionKey): string {
|
|
return $questionKey . '_note_before';
|
|
}
|
|
|
|
function qdb_note_after_key(string $questionKey): string {
|
|
return $questionKey . '_note_after';
|
|
}
|
|
|
|
/** Strip deprecated fields and persist questionKey + notes in config. */
|
|
function qdb_normalize_question_config(array $config, string $questionKey, ?string $noteBefore = null, ?string $noteAfter = null): array {
|
|
unset($config['multiline']);
|
|
$config['questionKey'] = $questionKey;
|
|
if ($noteBefore !== null) {
|
|
$nb = trim($noteBefore);
|
|
if ($nb !== '') {
|
|
$config['noteBefore'] = $nb;
|
|
} else {
|
|
unset($config['noteBefore']);
|
|
}
|
|
}
|
|
if ($noteAfter !== null) {
|
|
$na = trim($noteAfter);
|
|
if ($na !== '') {
|
|
$config['noteAfter'] = $na;
|
|
} else {
|
|
unset($config['noteAfter']);
|
|
}
|
|
}
|
|
return $config;
|
|
}
|
|
|
|
function qdb_parse_config_json($configJson): array {
|
|
if (is_array($configJson)) {
|
|
$config = $configJson;
|
|
} else {
|
|
$config = json_decode($configJson ?: '{}', true) ?: [];
|
|
}
|
|
unset($config['multiline']);
|
|
return $config;
|
|
}
|
|
|
|
/** Sync note German text into string_translation rows for the app. */
|
|
function qdb_sync_question_note_strings(PDO $pdo, string $questionKey, array $config): void {
|
|
if ($questionKey === '') {
|
|
return;
|
|
}
|
|
foreach ([
|
|
'noteBefore' => qdb_note_before_key($questionKey),
|
|
'noteAfter' => qdb_note_after_key($questionKey),
|
|
] as $field => $stringKey) {
|
|
$text = trim((string)($config[$field] ?? ''));
|
|
if ($text !== '') {
|
|
qdb_put_translation($pdo, 'string', $stringKey, QDB_SOURCE_LANGUAGE, $text);
|
|
}
|
|
}
|
|
}
|
|
|
|
function qdb_assert_unique_question_key(PDO $pdo, string $qnID, string $questionKey, ?string $excludeQuestionID = null): void {
|
|
$stmt = $pdo->prepare('SELECT questionID, configJson, defaultText FROM question WHERE questionnaireID = :qn');
|
|
$stmt->execute([':qn' => $qnID]);
|
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
|
if ($excludeQuestionID !== null && $row['questionID'] === $excludeQuestionID) {
|
|
continue;
|
|
}
|
|
$cfg = json_decode($row['configJson'] ?: '{}', true) ?: [];
|
|
$existing = qdb_question_key($cfg, $row['defaultText']);
|
|
if ($existing !== '' && $existing === $questionKey) {
|
|
json_error('CONFLICT', "Question key \"$questionKey\" already exists in this questionnaire", 409);
|
|
}
|
|
}
|
|
}
|
|
|
|
function qdb_assert_unique_option_key(PDO $pdo, string $questionID, string $optionKey, ?string $excludeOptionID = null): void {
|
|
$stmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
|
|
$stmt->execute([':qid' => $questionID]);
|
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
|
if ($excludeOptionID !== null && $row['answerOptionID'] === $excludeOptionID) {
|
|
continue;
|
|
}
|
|
if ($row['defaultText'] === $optionKey) {
|
|
json_error('CONFLICT', "Option key \"$optionKey\" already exists on this question", 409);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** German label for an option (translation de, else legacy defaultText when not a stable key). */
|
|
function qdb_option_german_label(PDO $pdo, string $answerOptionID, string $storedDefaultText): string {
|
|
$tr = qdb_fetch_translation_map($pdo, 'answer_option', $answerOptionID);
|
|
$de = trim($tr[QDB_SOURCE_LANGUAGE] ?? '');
|
|
if ($de !== '') {
|
|
return $de;
|
|
}
|
|
if (!qdb_is_stable_key($storedDefaultText)) {
|
|
return $storedDefaultText;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/** German label for a string catalog key (symptoms, glass levels, etc.). */
|
|
function qdb_string_german_label(PDO $pdo, string $stringKey, array &$cache = []): string {
|
|
$stringKey = trim($stringKey);
|
|
if ($stringKey === '') {
|
|
return '';
|
|
}
|
|
if (isset($cache[$stringKey])) {
|
|
return $cache[$stringKey];
|
|
}
|
|
$tr = qdb_fetch_translation_map($pdo, 'string', $stringKey);
|
|
$de = trim($tr[QDB_SOURCE_LANGUAGE] ?? '');
|
|
$label = $de !== '' ? $de : $stringKey;
|
|
$cache[$stringKey] = $label;
|
|
return $label;
|
|
}
|
|
|
|
/** German question text for UI display. */
|
|
function qdb_question_german_label(PDO $pdo, array $questionRow): string {
|
|
$tr = qdb_fetch_translation_map($pdo, 'question', $questionRow['questionID']);
|
|
$de = trim($tr[QDB_SOURCE_LANGUAGE] ?? '');
|
|
if ($de !== '') {
|
|
return $de;
|
|
}
|
|
$defaultText = trim($questionRow['defaultText'] ?? '');
|
|
if ($defaultText !== '' && !qdb_is_stable_key($defaultText)) {
|
|
return $defaultText;
|
|
}
|
|
return qdb_question_local_id($questionRow['questionID']);
|
|
}
|
|
|
|
/**
|
|
* Result columns + option map using German labels (client detail, not CSV export).
|
|
*
|
|
* @return array{questions: array, resultColumns: array, optionTextMap: array, stringLabelCache: array}
|
|
*/
|
|
function qdb_display_questionnaire_context(PDO $pdo, string $questionnaireID): array {
|
|
$qStmt = $pdo->prepare(
|
|
'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex'
|
|
);
|
|
$qStmt->execute([':id' => $questionnaireID]);
|
|
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
|
$stringLabelCache = [];
|
|
$resultColumns = [];
|
|
|
|
foreach ($questions as $q) {
|
|
$cfg = json_decode($q['configJson'] ?? '{}', true) ?: [];
|
|
$type = $q['type'] ?? '';
|
|
if ($type === 'glass_scale_question') {
|
|
$symptoms = $cfg['symptoms'] ?? [];
|
|
if (is_array($symptoms) && $symptoms !== []) {
|
|
foreach ($symptoms as $symptomKey) {
|
|
$sk = trim((string)$symptomKey);
|
|
if ($sk === '') {
|
|
continue;
|
|
}
|
|
$resultColumns[] = [
|
|
'header' => qdb_string_german_label($pdo, $sk, $stringLabelCache),
|
|
'questionID' => $q['questionID'],
|
|
'symptomKey' => $sk,
|
|
'kind' => 'glass_symptom',
|
|
'question' => $q,
|
|
];
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
$resultColumns[] = [
|
|
'header' => qdb_question_german_label($pdo, $q),
|
|
'questionID' => $q['questionID'],
|
|
'symptomKey' => null,
|
|
'kind' => 'question',
|
|
'question' => $q,
|
|
];
|
|
}
|
|
|
|
$optionTextMap = [];
|
|
foreach ($questions as $q) {
|
|
$aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
|
|
$aoStmt->execute([':qid' => $q['questionID']]);
|
|
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
|
$optionTextMap[$ao['answerOptionID']] = qdb_option_german_label(
|
|
$pdo,
|
|
$ao['answerOptionID'],
|
|
$ao['defaultText']
|
|
);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'questions' => $questions,
|
|
'resultColumns' => $resultColumns,
|
|
'optionTextMap' => $optionTextMap,
|
|
'stringLabelCache' => $stringLabelCache,
|
|
];
|
|
}
|
|
|
|
/** Format one answer cell for UI (German labels). */
|
|
function qdb_display_column_cell_value(
|
|
PDO $pdo,
|
|
array $column,
|
|
?array $answerRow,
|
|
array $optionTextMap,
|
|
array &$stringLabelCache
|
|
): string {
|
|
if (($column['kind'] ?? '') === 'glass_symptom') {
|
|
$raw = qdb_glass_symptom_answer_value(
|
|
$answerRow['freeTextValue'] ?? null,
|
|
(string)($column['symptomKey'] ?? '')
|
|
);
|
|
if ($raw === '') {
|
|
return '';
|
|
}
|
|
return qdb_string_german_label($pdo, $raw, $stringLabelCache);
|
|
}
|
|
if (($column['question']['type'] ?? '') === 'glass_scale_question') {
|
|
return qdb_format_glass_answer_json_display($pdo, $answerRow['freeTextValue'] ?? null, $stringLabelCache);
|
|
}
|
|
return qdb_format_client_answer_display($column['question'], $answerRow, $optionTextMap);
|
|
}
|
|
|
|
/** Glass-scale JSON with German symptom keys and level labels. */
|
|
function qdb_format_glass_answer_json_display(PDO $pdo, ?string $raw, array &$stringLabelCache): string
|
|
{
|
|
if ($raw === null || trim($raw) === '') {
|
|
return '';
|
|
}
|
|
$decoded = json_decode($raw, true);
|
|
if (!is_array($decoded)) {
|
|
return $raw;
|
|
}
|
|
$parts = [];
|
|
foreach ($decoded as $key => $val) {
|
|
$val = trim((string)$val);
|
|
if ($val === '') {
|
|
continue;
|
|
}
|
|
$symLabel = qdb_string_german_label($pdo, (string)$key, $stringLabelCache);
|
|
$levelLabel = qdb_string_german_label($pdo, $val, $stringLabelCache);
|
|
$parts[] = $symLabel . ': ' . $levelLabel;
|
|
}
|
|
return implode('; ', $parts);
|
|
}
|
|
|
|
/**
|
|
* Build translation entry lists for one questionnaire.
|
|
* Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved).
|
|
*/
|
|
function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
|
|
$qStmt = $pdo->prepare("
|
|
SELECT q.questionID, q.defaultText, q.configJson, q.orderIndex
|
|
FROM question q WHERE q.questionnaireID = :id ORDER BY q.orderIndex
|
|
");
|
|
$qStmt->execute([':id' => $qnID]);
|
|
$dbQuestions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
$stringKeys = [];
|
|
$stringEntries = [];
|
|
$contentEntries = [];
|
|
|
|
foreach ($dbQuestions as $dbQ) {
|
|
$qOrder = (int)($dbQ['orderIndex'] ?? 0);
|
|
$qLocal = qdb_question_local_id($dbQ['questionID']);
|
|
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
|
$qKey = qdb_question_key($config, $dbQ['defaultText']);
|
|
$germanQ = trim($dbQ['defaultText']);
|
|
if ($qKey !== '' && qdb_is_stable_key($germanQ) && $germanQ === $qKey) {
|
|
$germanQ = '';
|
|
}
|
|
$contentEntries[] = [
|
|
'key' => $qKey !== '' ? $qKey : $dbQ['defaultText'],
|
|
'displayKey' => $qKey !== '' ? $qKey : (preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal),
|
|
'germanText' => $germanQ,
|
|
'type' => 'question',
|
|
'entityId' => $dbQ['questionID'],
|
|
'sortOrder' => $qOrder * 1000,
|
|
];
|
|
|
|
$aoStmt = $pdo->prepare("
|
|
SELECT answerOptionID, defaultText, orderIndex
|
|
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
|
");
|
|
$aoStmt->execute([':qid' => $dbQ['questionID']]);
|
|
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
|
$optKey = qdb_option_key($ao);
|
|
$optLabel = $optKey !== ''
|
|
? ($qLocal . ' · ' . $optKey)
|
|
: (preg_match('/^[a-f0-9]{32}$/i', $qLocal)
|
|
? ('#' . ($qOrder + 1) . ' · option ' . ((int)$ao['orderIndex'] + 1))
|
|
: ($qLocal . ' · option ' . ((int)$ao['orderIndex'] + 1)));
|
|
$contentEntries[] = [
|
|
'key' => $optKey !== '' ? $optKey : $ao['defaultText'],
|
|
'displayKey' => $optLabel,
|
|
'germanText' => qdb_option_german_label($pdo, $ao['answerOptionID'], $ao['defaultText']),
|
|
'type' => 'answer_option',
|
|
'entityId' => $ao['answerOptionID'],
|
|
'sortOrder' => $qOrder * 1000 + 10 + (int)($ao['orderIndex'] ?? 0),
|
|
];
|
|
}
|
|
|
|
foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2', 'otherOptionKey'] as $field) {
|
|
if (!empty($config[$field])) {
|
|
$stringKeys[$config[$field]] = true;
|
|
}
|
|
}
|
|
if (!empty($config['symptoms']) && is_array($config['symptoms'])) {
|
|
foreach ($config['symptoms'] as $s) {
|
|
$stringKeys[$s] = true;
|
|
}
|
|
}
|
|
if ($qKey !== '') {
|
|
if (!empty($config['noteBefore'])) {
|
|
$stringKeys[qdb_note_before_key($qKey)] = true;
|
|
}
|
|
if (!empty($config['noteAfter'])) {
|
|
$stringKeys[qdb_note_after_key($qKey)] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
$appKeySet = qdb_app_string_key_set();
|
|
$stringOrder = 0;
|
|
$skList = array_keys($stringKeys);
|
|
sort($skList, SORT_STRING);
|
|
foreach ($skList as $sk) {
|
|
if (isset($appKeySet[$sk])) {
|
|
continue;
|
|
}
|
|
$stringEntries[] = [
|
|
'key' => $sk,
|
|
'displayKey' => $sk,
|
|
'type' => 'string',
|
|
'entityId' => $sk,
|
|
'sortOrder' => $stringOrder++,
|
|
];
|
|
}
|
|
|
|
return ['stringEntries' => $stringEntries, 'contentEntries' => $contentEntries];
|
|
}
|
|
|
|
/** Attach translation texts and germanText to entry rows. */
|
|
function qdb_attach_translation_texts(array &$entries, array $translations): void {
|
|
foreach ($entries as &$e) {
|
|
$tr = $translations[$e['entityId']] ?? [];
|
|
$e['translations'] = (object)$tr;
|
|
$de = trim($tr[QDB_SOURCE_LANGUAGE] ?? '');
|
|
if ($de !== '') {
|
|
$e['germanText'] = $de;
|
|
} elseif (!empty($e['germanDefault'])) {
|
|
$e['germanText'] = $e['germanDefault'];
|
|
} else {
|
|
$e['germanText'] = $e['key'];
|
|
}
|
|
unset($e['germanDefault']);
|
|
}
|
|
unset($e);
|
|
}
|
|
|
|
/** Load all translation rows for a flat entry list. */
|
|
function qdb_load_translations_for_entries(PDO $pdo, array $entries): array {
|
|
$translations = [];
|
|
|
|
$qIds = array_values(array_filter(array_map(
|
|
fn($e) => $e['type'] === 'question' ? $e['entityId'] : null,
|
|
$entries
|
|
)));
|
|
if ($qIds) {
|
|
$ph = implode(',', array_fill(0, count($qIds), '?'));
|
|
$stmt = $pdo->prepare("SELECT questionID AS entityId, languageCode, text FROM question_translation WHERE questionID IN ($ph)");
|
|
$stmt->execute($qIds);
|
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
|
}
|
|
}
|
|
|
|
$aoIds = array_values(array_filter(array_map(
|
|
fn($e) => $e['type'] === 'answer_option' ? $e['entityId'] : null,
|
|
$entries
|
|
)));
|
|
if ($aoIds) {
|
|
$ph = implode(',', array_fill(0, count($aoIds), '?'));
|
|
$stmt = $pdo->prepare("SELECT answerOptionID AS entityId, languageCode, text FROM answer_option_translation WHERE answerOptionID IN ($ph)");
|
|
$stmt->execute($aoIds);
|
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
|
}
|
|
}
|
|
|
|
$sKeys = array_values(array_filter(array_map(
|
|
fn($e) => ($e['type'] === 'string' || $e['type'] === 'app_string') ? $e['entityId'] : null,
|
|
$entries
|
|
)));
|
|
if ($sKeys) {
|
|
$ph = implode(',', array_fill(0, count($sKeys), '?'));
|
|
$stmt = $pdo->prepare("SELECT stringKey AS entityId, languageCode, text FROM string_translation WHERE stringKey IN ($ph)");
|
|
$stmt->execute($sKeys);
|
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
|
}
|
|
}
|
|
|
|
return $translations;
|
|
}
|
|
|
|
/**
|
|
* Flat language -> stringKey -> text map for global app UI strings only.
|
|
* @return array<string, array<string, string>>
|
|
*/
|
|
function qdb_build_app_translations_map(PDO $pdo): array {
|
|
$entries = qdb_app_ui_string_entries_merged($pdo);
|
|
$translations = qdb_load_translations_for_entries($pdo, $entries);
|
|
$defaults = qdb_app_string_catalog()['germanDefaults'] ?? [];
|
|
$result = [];
|
|
foreach ($entries as $e) {
|
|
$key = $e['key'];
|
|
foreach ($translations[$e['entityId']] ?? [] as $lang => $text) {
|
|
$result[$lang][$key] = $text;
|
|
}
|
|
if (!isset($result[QDB_SOURCE_LANGUAGE][$key]) && !empty($defaults[$key])) {
|
|
$result[QDB_SOURCE_LANGUAGE][$key] = $defaults[$key];
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Flat language -> stringKey -> text map for one questionnaire (questions, options, config strings).
|
|
* Excludes keys from the global app UI catalog.
|
|
* @return array<string, array<string, string>>
|
|
*/
|
|
function qdb_build_questionnaire_translations_map(PDO $pdo, string $qnID): array {
|
|
$lists = qdb_translation_entry_lists($pdo, $qnID);
|
|
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
|
|
if (!$entries) {
|
|
return [];
|
|
}
|
|
$translations = qdb_load_translations_for_entries($pdo, $entries);
|
|
$result = [];
|
|
foreach ($entries as $e) {
|
|
$key = $e['key'];
|
|
foreach ($translations[$e['entityId']] ?? [] as $lang => $text) {
|
|
$result[$lang][$key] = $text;
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/** @return array<string, string> */
|
|
function qdb_translation_rows_to_map(array $rows): array {
|
|
$map = [];
|
|
foreach ($rows as $row) {
|
|
if (is_array($row) && isset($row['languageCode'])) {
|
|
$map[$row['languageCode']] = $row['text'] ?? '';
|
|
}
|
|
}
|
|
return $map;
|
|
}
|
|
|
|
/** @return array<string, string> */
|
|
function qdb_fetch_translation_map(PDO $pdo, string $type, string $entityId): array {
|
|
if ($type === 'question') {
|
|
$stmt = $pdo->prepare('SELECT languageCode, text FROM question_translation WHERE questionID = :id');
|
|
} elseif ($type === 'answer_option') {
|
|
$stmt = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id');
|
|
} else {
|
|
$stmt = $pdo->prepare('SELECT languageCode, text FROM string_translation WHERE stringKey = :id');
|
|
}
|
|
$stmt->execute([':id' => $entityId]);
|
|
return qdb_translation_rows_to_map($stmt->fetchAll(PDO::FETCH_ASSOC));
|
|
}
|
|
|
|
function qdb_apply_translation_map(PDO $pdo, string $type, string $entityId, array $map): void {
|
|
foreach ($map as $lang => $text) {
|
|
if (!is_string($lang) || $lang === '') {
|
|
continue;
|
|
}
|
|
qdb_put_translation($pdo, $type, $entityId, $lang, (string)$text);
|
|
}
|
|
}
|
|
|
|
function qdb_question_local_id(string $questionID, ?string $questionnaireID = null): string {
|
|
$pos = strrpos($questionID, '__');
|
|
return $pos !== false ? substr($questionID, $pos + 2) : $questionID;
|
|
}
|
|
|
|
/** Column / export header: stable question key, else local id (q1, q2, …). */
|
|
function qdb_question_column_label(array $config, string $defaultText, string $questionID, ?string $questionnaireID = null): string {
|
|
$key = qdb_question_key($config, $defaultText);
|
|
return $key !== '' ? $key : qdb_question_local_id($questionID, $questionnaireID);
|
|
}
|
|
|
|
function qdb_make_question_id(string $questionnaireID, string $localId): string {
|
|
return $questionnaireID . '__' . $localId;
|
|
}
|
|
|
|
/** Next short id for a new question (q1, q2, …) within one questionnaire. */
|
|
function qdb_allocate_question_local_id(PDO $pdo, string $questionnaireID): string {
|
|
$stmt = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
|
|
$stmt->execute([':id' => $questionnaireID]);
|
|
$max = 0;
|
|
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $questionID) {
|
|
$local = qdb_question_local_id($questionID);
|
|
if (preg_match('/^q(\d+)$/i', $local, $m)) {
|
|
$max = max($max, (int)$m[1]);
|
|
}
|
|
}
|
|
return 'q' . ($max + 1);
|
|
}
|
|
|
|
/** Human-readable Key column label (not the app translation lookup key). */
|
|
function qdb_translation_row_label(array $e): string {
|
|
if (!empty($e['displayKey']) && !preg_match('/^[a-f0-9]{32}$/i', $e['displayKey'])) {
|
|
return $e['displayKey'];
|
|
}
|
|
$type = $e['type'] ?? '';
|
|
$key = $e['key'] ?? '';
|
|
if ($type === 'question' || $type === 'answer_option') {
|
|
if ($key !== '') {
|
|
$short = mb_strlen($key) > 56 ? mb_substr($key, 0, 56) . '…' : $key;
|
|
return $short;
|
|
}
|
|
}
|
|
$id = $e['entityId'] ?? '';
|
|
$pos = strrpos($id, '__');
|
|
if ($pos !== false) {
|
|
return substr($id, $pos + 2);
|
|
}
|
|
return $key !== '' ? $key : $id;
|
|
}
|
|
|
|
/** Export one questionnaire with structure + all translations (portable JSON). */
|
|
function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
|
|
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
|
|
$qn->execute([':id' => $qnID]);
|
|
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
|
|
if (!$qnRow) {
|
|
return null;
|
|
}
|
|
|
|
$qStmt = $pdo->prepare("
|
|
SELECT questionID, defaultText, type, orderIndex, isRequired, configJson
|
|
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
|
|
");
|
|
$qStmt->execute([':id' => $qnID]);
|
|
$questionsOut = [];
|
|
|
|
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $dbQ) {
|
|
$localId = qdb_question_local_id($dbQ['questionID'], $qnID);
|
|
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
|
|
|
$optionsOut = [];
|
|
$aoStmt = $pdo->prepare("
|
|
SELECT answerOptionID, defaultText, points, orderIndex, nextQuestionId
|
|
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
|
");
|
|
$aoStmt->execute([':qid' => $dbQ['questionID']]);
|
|
$qKey = qdb_question_key($config, $dbQ['defaultText']);
|
|
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
|
$optKey = qdb_option_key($ao);
|
|
$optTr = qdb_fetch_translation_map($pdo, 'answer_option', $ao['answerOptionID']);
|
|
$optGerman = trim($optTr[QDB_SOURCE_LANGUAGE] ?? '');
|
|
if ($optGerman === '' && !qdb_is_stable_key($ao['defaultText'])) {
|
|
$optGerman = $ao['defaultText'];
|
|
}
|
|
$optionsOut[] = [
|
|
'optionKey' => $optKey !== '' ? $optKey : $ao['defaultText'],
|
|
'defaultText' => $optGerman,
|
|
'points' => (int)$ao['points'],
|
|
'orderIndex' => (int)$ao['orderIndex'],
|
|
'nextQuestionId' => $ao['nextQuestionId'] ?? '',
|
|
'translations' => $optTr,
|
|
];
|
|
}
|
|
|
|
$questionsOut[] = [
|
|
'localId' => $localId,
|
|
'questionKey' => $qKey,
|
|
'defaultText' => $dbQ['defaultText'],
|
|
'type' => $dbQ['type'],
|
|
'orderIndex' => (int)$dbQ['orderIndex'],
|
|
'isRequired' => (int)$dbQ['isRequired'],
|
|
'config' => $config,
|
|
'answerOptions' => $optionsOut,
|
|
'translations' => qdb_fetch_translation_map($pdo, 'question', $dbQ['questionID']),
|
|
];
|
|
}
|
|
|
|
$lists = qdb_translation_entry_lists($pdo, $qnID);
|
|
$stringTranslations = [];
|
|
if (!empty($lists['stringEntries'])) {
|
|
$tr = qdb_load_translations_for_entries($pdo, $lists['stringEntries']);
|
|
foreach ($lists['stringEntries'] as $e) {
|
|
$stringTranslations[] = [
|
|
'stringKey' => $e['key'],
|
|
'translations' => $tr[$e['entityId']] ?? [],
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'questionnaire' => [
|
|
'questionnaireID' => $qnRow['questionnaireID'],
|
|
'name' => $qnRow['name'],
|
|
'version' => $qnRow['version'],
|
|
'state' => $qnRow['state'],
|
|
'orderIndex' => (int)$qnRow['orderIndex'],
|
|
'showPoints' => (int)$qnRow['showPoints'],
|
|
'conditionJson' => $qnRow['conditionJson'] ?: '{}',
|
|
],
|
|
'questions' => $questionsOut,
|
|
'stringTranslations' => $stringTranslations,
|
|
'translationsFlat' => qdb_build_questionnaire_translations_map($pdo, $qnID),
|
|
];
|
|
}
|
|
|
|
/** Export every questionnaire as a portable bundle. */
|
|
function qdb_export_all_questionnaires_bundle(PDO $pdo): array {
|
|
$ids = $pdo->query('SELECT questionnaireID FROM questionnaire ORDER BY orderIndex, name')
|
|
->fetchAll(PDO::FETCH_COLUMN);
|
|
$items = [];
|
|
foreach ($ids as $id) {
|
|
$item = qdb_export_questionnaire_bundle($pdo, $id);
|
|
if ($item !== null) {
|
|
$items[] = $item;
|
|
}
|
|
}
|
|
return [
|
|
'exportVersion' => 1,
|
|
'exportedAt' => time(),
|
|
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
|
|
'questionnaireCount' => count($items),
|
|
'questionnaires' => $items,
|
|
];
|
|
}
|
|
|
|
/** Remove questionnaire and related rows (no client_answer cleanup for other qns). */
|
|
function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void {
|
|
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
|
|
$qIds->execute([':id' => $qnID]);
|
|
$questionIDs = $qIds->fetchAll(PDO::FETCH_COLUMN);
|
|
|
|
foreach ($questionIDs as $qid) {
|
|
$aoIds = $pdo->prepare('SELECT answerOptionID FROM answer_option WHERE questionID = :qid');
|
|
$aoIds->execute([':qid' => $qid]);
|
|
foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) {
|
|
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')
|
|
->execute([':id' => $aoid]);
|
|
}
|
|
$pdo->prepare('DELETE FROM answer_option WHERE questionID = :qid')->execute([':qid' => $qid]);
|
|
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :qid')->execute([':qid' => $qid]);
|
|
$pdo->prepare('DELETE FROM client_answer WHERE questionID = :qid')->execute([':qid' => $qid]);
|
|
}
|
|
$pdo->prepare('DELETE FROM question WHERE questionnaireID = :id')->execute([':id' => $qnID]);
|
|
$pdo->prepare('DELETE FROM completed_questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]);
|
|
$pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]);
|
|
}
|
|
|
|
/**
|
|
* Import one questionnaire from a bundle item.
|
|
* @return array{questionnaireID: string, created: bool, replaced: bool}
|
|
*/
|
|
function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfExists = false): array {
|
|
$meta = $item['questionnaire'] ?? [];
|
|
if (empty($meta['name'])) {
|
|
json_error('MISSING_FIELDS', 'questionnaire.name is required', 400);
|
|
}
|
|
|
|
$wantedId = trim($meta['questionnaireID'] ?? '');
|
|
$chk = $wantedId !== '' ? $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id') : null;
|
|
if ($chk) {
|
|
$chk->execute([':id' => $wantedId]);
|
|
}
|
|
$exists = $chk && (bool)$chk->fetch();
|
|
|
|
$replaced = false;
|
|
if ($exists && $replaceIfExists) {
|
|
qdb_delete_questionnaire_cascade($pdo, $wantedId);
|
|
$replaced = true;
|
|
$qnID = $wantedId;
|
|
} elseif ($exists) {
|
|
$qnID = bin2hex(random_bytes(16));
|
|
} elseif ($wantedId !== '') {
|
|
$qnID = $wantedId;
|
|
} else {
|
|
$qnID = bin2hex(random_bytes(16));
|
|
}
|
|
|
|
$order = (int)($meta['orderIndex'] ?? 0);
|
|
if ($order === 0) {
|
|
$order = (int)$pdo->query('SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire')->fetchColumn();
|
|
}
|
|
|
|
$condJson = $meta['conditionJson'] ?? '{}';
|
|
if (is_array($condJson)) {
|
|
$condJson = json_encode($condJson, JSON_UNESCAPED_UNICODE);
|
|
}
|
|
|
|
$catKey = trim($meta['categoryKey'] ?? '');
|
|
$pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
|
|
VALUES (:id, :n, :v, :s, :o, :sp, :cj, :ck)")
|
|
->execute([
|
|
':id' => $qnID,
|
|
':n' => trim($meta['name']),
|
|
':v' => trim($meta['version'] ?? ''),
|
|
':s' => trim($meta['state'] ?? 'draft'),
|
|
':o' => $order,
|
|
':sp' => (int)($meta['showPoints'] ?? 0),
|
|
':cj' => $condJson ?: '{}',
|
|
':ck' => $catKey,
|
|
]);
|
|
|
|
foreach ($item['questions'] ?? [] as $q) {
|
|
$localId = trim($q['localId'] ?? '');
|
|
if ($localId === '') {
|
|
$localId = 'q_' . bin2hex(random_bytes(4));
|
|
}
|
|
$questionID = $qnID . '__' . $localId;
|
|
|
|
$config = qdb_parse_config_json($q['config'] ?? $q['configJson'] ?? []);
|
|
$questionKey = trim((string)($q['questionKey'] ?? $config['questionKey'] ?? ''));
|
|
$text = trim($q['defaultText'] ?? '');
|
|
$qTr = $q['translations'] ?? [];
|
|
if (is_array($qTr) && isset($qTr[0]['languageCode'])) {
|
|
$qTr = qdb_translation_rows_to_map($qTr);
|
|
}
|
|
if ($questionKey === '' && qdb_is_stable_key($text) && trim($qTr[QDB_SOURCE_LANGUAGE] ?? '') !== '' && $qTr[QDB_SOURCE_LANGUAGE] !== $text) {
|
|
$questionKey = $text;
|
|
$text = trim($qTr[QDB_SOURCE_LANGUAGE]);
|
|
}
|
|
if ($questionKey === '') {
|
|
json_error(
|
|
'MISSING_FIELDS',
|
|
"Question key is required for {$qnID} / {$localId} (stable key [a-z][a-z0-9_]*)",
|
|
400
|
|
);
|
|
}
|
|
$questionKey = qdb_validate_stable_key($questionKey, 'Question key');
|
|
// Duplicate question keys in one questionnaire are allowed (same prompt in different branches).
|
|
|
|
qdb_require_non_empty_german($text, 'Question German text');
|
|
$noteBefore = $config['noteBefore'] ?? null;
|
|
$noteAfter = $config['noteAfter'] ?? null;
|
|
$config = qdb_normalize_question_config($config, $questionKey, $noteBefore, $noteAfter);
|
|
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
|
|
|
|
$pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
|
|
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)")
|
|
->execute([
|
|
':id' => $questionID,
|
|
':qid' => $qnID,
|
|
':t' => $text,
|
|
':ty' => trim($q['type'] ?? ''),
|
|
':o' => (int)($q['orderIndex'] ?? 0),
|
|
':r' => (int)($q['isRequired'] ?? 0),
|
|
':cj' => $configJson,
|
|
]);
|
|
|
|
qdb_upsert_source_translation($pdo, 'question', $questionID, $text);
|
|
qdb_apply_translation_map($pdo, 'question', $questionID, is_array($qTr) ? $qTr : []);
|
|
qdb_sync_question_note_strings($pdo, $questionKey, $config);
|
|
|
|
foreach ($q['answerOptions'] ?? [] as $opt) {
|
|
$optKey = trim((string)($opt['optionKey'] ?? ''));
|
|
$optGerman = trim($opt['defaultText'] ?? '');
|
|
$oTr = $opt['translations'] ?? [];
|
|
if (is_array($oTr) && isset($oTr[0]['languageCode'])) {
|
|
$oTr = qdb_translation_rows_to_map($oTr);
|
|
}
|
|
if ($optKey === '' && qdb_is_stable_key($optGerman) && trim($oTr[QDB_SOURCE_LANGUAGE] ?? '') !== '' && $oTr[QDB_SOURCE_LANGUAGE] !== $optGerman) {
|
|
$optKey = $optGerman;
|
|
$optGerman = trim($oTr[QDB_SOURCE_LANGUAGE]);
|
|
}
|
|
if ($optKey === '' && qdb_is_stable_key($optGerman)) {
|
|
$optKey = $optGerman;
|
|
$optGerman = trim($oTr[QDB_SOURCE_LANGUAGE] ?? $optGerman);
|
|
}
|
|
if ($optKey === '') {
|
|
json_error(
|
|
'MISSING_FIELDS',
|
|
"Option key is required for {$qnID} / {$localId} option #" . ((int)($opt['orderIndex'] ?? 0) + 1),
|
|
400
|
|
);
|
|
}
|
|
$optKey = qdb_validate_stable_key($optKey, 'Option key');
|
|
if ($optGerman === '') {
|
|
$optGerman = trim($oTr[QDB_SOURCE_LANGUAGE] ?? '');
|
|
}
|
|
qdb_require_non_empty_german($optGerman, 'Answer option German text');
|
|
|
|
$aoId = bin2hex(random_bytes(16));
|
|
$pdo->prepare("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
|
|
VALUES (:id, :qid, :t, :p, :o, :nq)")
|
|
->execute([
|
|
':id' => $aoId,
|
|
':qid' => $questionID,
|
|
':t' => $optKey,
|
|
':p' => (int)($opt['points'] ?? 0),
|
|
':o' => (int)($opt['orderIndex'] ?? 0),
|
|
':nq' => trim($opt['nextQuestionId'] ?? ''),
|
|
]);
|
|
qdb_upsert_source_translation($pdo, 'answer_option', $aoId, $optGerman);
|
|
qdb_apply_translation_map($pdo, 'answer_option', $aoId, is_array($oTr) ? $oTr : []);
|
|
}
|
|
}
|
|
|
|
foreach ($item['stringTranslations'] ?? [] as $st) {
|
|
$sk = trim($st['stringKey'] ?? '');
|
|
if ($sk === '') {
|
|
continue;
|
|
}
|
|
$map = $st['translations'] ?? [];
|
|
if (is_array($map) && isset($map[0]['languageCode'])) {
|
|
$map = qdb_translation_rows_to_map($map);
|
|
}
|
|
qdb_apply_translation_map($pdo, 'string', $sk, is_array($map) ? $map : []);
|
|
}
|
|
|
|
return [
|
|
'questionnaireID' => $qnID,
|
|
'created' => true,
|
|
'replaced' => $replaced,
|
|
];
|
|
}
|
|
|
|
/** One translation row for a portable translations JSON bundle. */
|
|
function qdb_translation_entry_to_bundle_row(array $e, ?string $questionnaireID = null, ?string $questionnaireName = null): array {
|
|
$tr = $e['translations'] ?? [];
|
|
if ($tr instanceof stdClass) {
|
|
$tr = (array)$tr;
|
|
}
|
|
if (is_array($tr) && isset($tr[0]) && is_array($tr[0]) && isset($tr[0]['languageCode'])) {
|
|
$tr = qdb_translation_rows_to_map($tr);
|
|
}
|
|
$row = [
|
|
'type' => $e['type'],
|
|
'entityId' => $e['entityId'],
|
|
'key' => $e['key'] ?? '',
|
|
'translations' => is_array($tr) ? $tr : [],
|
|
];
|
|
if (!empty($e['displayKey'])) {
|
|
$row['displayKey'] = $e['displayKey'];
|
|
}
|
|
if ($questionnaireID !== null && $questionnaireID !== '') {
|
|
$row['questionnaireID'] = $questionnaireID;
|
|
}
|
|
if ($questionnaireName !== null && $questionnaireName !== '') {
|
|
$row['questionnaireName'] = $questionnaireName;
|
|
}
|
|
return $row;
|
|
}
|
|
|
|
/** @return array<string, string> */
|
|
function qdb_normalize_bundle_translations($raw): array {
|
|
if (!is_array($raw)) {
|
|
return [];
|
|
}
|
|
if (isset($raw[0]) && is_array($raw[0]) && isset($raw[0]['languageCode'])) {
|
|
return qdb_translation_rows_to_map($raw);
|
|
}
|
|
$map = [];
|
|
foreach ($raw as $lang => $text) {
|
|
if (is_string($lang) && $lang !== '') {
|
|
$map[$lang] = (string)$text;
|
|
}
|
|
}
|
|
return $map;
|
|
}
|
|
|
|
function qdb_translation_entity_exists(PDO $pdo, string $type, string $entityId): bool {
|
|
if ($type === 'string' || $type === 'app_string') {
|
|
return true;
|
|
}
|
|
if ($type === 'question') {
|
|
$stmt = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id LIMIT 1');
|
|
} elseif ($type === 'answer_option') {
|
|
$stmt = $pdo->prepare('SELECT 1 FROM answer_option WHERE answerOptionID = :id LIMIT 1');
|
|
} else {
|
|
return false;
|
|
}
|
|
$stmt->execute([':id' => $entityId]);
|
|
return (bool)$stmt->fetchColumn();
|
|
}
|
|
|
|
/** Export all translations (languages + flat entries) as portable JSON. */
|
|
function qdb_export_translations_bundle(PDO $pdo): array {
|
|
$languages = $pdo->query('SELECT languageCode, name FROM language ORDER BY languageCode')
|
|
->fetchAll(PDO::FETCH_ASSOC);
|
|
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
|
|
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']);
|
|
}
|
|
qdb_ensure_source_language($pdo);
|
|
|
|
$exportEntries = [];
|
|
$appEntries = qdb_app_ui_string_entries_for_website($pdo);
|
|
$appTranslations = qdb_load_translations_for_entries($pdo, $appEntries);
|
|
qdb_attach_translation_texts($appEntries, $appTranslations);
|
|
foreach ($appEntries as $e) {
|
|
$exportEntries[] = qdb_translation_entry_to_bundle_row($e);
|
|
}
|
|
|
|
$qnRows = $pdo->query('SELECT questionnaireID, name FROM questionnaire ORDER BY orderIndex, name')
|
|
->fetchAll(PDO::FETCH_ASSOC);
|
|
foreach ($qnRows as $qn) {
|
|
$lists = qdb_translation_entry_lists($pdo, $qn['questionnaireID']);
|
|
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
|
|
if (!$entries) {
|
|
continue;
|
|
}
|
|
$translations = qdb_load_translations_for_entries($pdo, $entries);
|
|
qdb_attach_translation_texts($entries, $translations);
|
|
foreach ($entries as $e) {
|
|
$exportEntries[] = qdb_translation_entry_to_bundle_row(
|
|
$e,
|
|
$qn['questionnaireID'],
|
|
$qn['name']
|
|
);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'exportVersion' => 1,
|
|
'exportedAt' => time(),
|
|
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
|
|
'languageCount' => count($languages),
|
|
'languages' => $languages,
|
|
'entryCount' => count($exportEntries),
|
|
'entries' => $exportEntries,
|
|
];
|
|
}
|
|
|
|
/** Import translations bundle (upsert languages and translation rows). */
|
|
function qdb_import_translations_bundle(PDO $pdo, array $bundle): array {
|
|
$entries = $bundle['entries'] ?? null;
|
|
if (!is_array($entries)) {
|
|
json_error('MISSING_FIELDS', 'bundle.entries array is required', 400);
|
|
}
|
|
|
|
qdb_ensure_source_language($pdo);
|
|
foreach ($bundle['languages'] ?? [] as $langRow) {
|
|
if (!is_array($langRow)) {
|
|
continue;
|
|
}
|
|
$lc = trim($langRow['languageCode'] ?? '');
|
|
$name = trim($langRow['name'] ?? '');
|
|
if ($lc === '' || $lc === QDB_SOURCE_LANGUAGE) {
|
|
continue;
|
|
}
|
|
$pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n)
|
|
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name')
|
|
->execute([':lc' => $lc, ':n' => $name]);
|
|
}
|
|
|
|
$validTypes = ['question', 'answer_option', 'string', 'app_string'];
|
|
$imported = 0;
|
|
$skipped = 0;
|
|
|
|
foreach ($entries as $entry) {
|
|
if (!is_array($entry)) {
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
$type = $entry['type'] ?? '';
|
|
$entityId = trim((string)($entry['entityId'] ?? ''));
|
|
if (!in_array($type, $validTypes, true) || $entityId === '') {
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
if (!qdb_translation_entity_exists($pdo, $type, $entityId)) {
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
$map = qdb_normalize_bundle_translations($entry['translations'] ?? []);
|
|
if ($map) {
|
|
qdb_apply_translation_map($pdo, $type, $entityId, $map);
|
|
$imported++;
|
|
} else {
|
|
$skipped++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'imported' => $imported,
|
|
'skipped' => $skipped,
|
|
'total' => count($entries),
|
|
];
|
|
}
|
|
|
|
/** Import a full export bundle. */
|
|
function qdb_import_questionnaires_bundle(PDO $pdo, array $bundle, bool $replaceIfExists = false): array {
|
|
$items = $bundle['questionnaires'] ?? [];
|
|
if (!is_array($items) || !$items) {
|
|
json_error('MISSING_FIELDS', 'bundle.questionnaires array is required', 400);
|
|
}
|
|
$results = [];
|
|
foreach ($items as $item) {
|
|
$results[] = qdb_import_questionnaire_bundle($pdo, $item, $replaceIfExists);
|
|
}
|
|
return [
|
|
'imported' => count($results),
|
|
'results' => $results,
|
|
];
|
|
}
|
|
|
|
// --- AES-256-CBC: IV(16) + CIPHERTEXT ---
|
|
function aes256_cbc_encrypt_bytes(string $plain, string $key): string {
|
|
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
|
$iv = random_bytes(16);
|
|
$cipher = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
|
if ($cipher === false) throw new Exception('openssl_encrypt failed');
|
|
return $iv . $cipher;
|
|
}
|
|
function aes256_cbc_decrypt_bytes(string $data, string $key): string {
|
|
if (strlen($data) < 16) throw new Exception('cipher too short');
|
|
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
|
$iv = substr($data, 0, 16);
|
|
$ct = substr($data, 16);
|
|
$plain = openssl_decrypt($ct, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
|
if ($plain === false) throw new Exception('openssl_decrypt failed');
|
|
return $plain;
|
|
}
|