new system prototype
This commit is contained in:
6
api/.htaccess
Normal file
6
api/.htaccess
Normal file
@ -0,0 +1,6 @@
|
||||
RewriteEngine On
|
||||
|
||||
# Let the front controller handle everything except itself
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} !=/api/index.php
|
||||
RewriteRule ^(.*)$ index.php [QSA,L]
|
||||
185
api/answer_options.php
Normal file
185
api/answer_options.php
Normal file
@ -0,0 +1,185 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
$qID = $_GET['questionID'] ?? '';
|
||||
if (!$qID) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionID query param required"]);
|
||||
exit;
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId
|
||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
||||
");
|
||||
$stmt->execute([':qid' => $qID]);
|
||||
$options = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($options as &$o) {
|
||||
$o['points'] = (int)$o['points'];
|
||||
$o['orderIndex'] = (int)$o['orderIndex'];
|
||||
$tr = $pdo->prepare("SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id");
|
||||
$tr->execute([':id' => $o['answerOptionID']]);
|
||||
$o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
unset($o);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "answerOptions" => $options]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['questionID']) || !isset($body['defaultText'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionID and defaultText required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$qID = $body['questionID'];
|
||||
$text = trim($body['defaultText']);
|
||||
$points = (int)($body['points'] ?? 0);
|
||||
$order = (int)($body['orderIndex'] ?? 0);
|
||||
$nextQ = trim($body['nextQuestionId'] ?? '');
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$chk = $pdo->prepare("SELECT 1 FROM question WHERE questionID = :id");
|
||||
$chk->execute([':id' => $qID]);
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Question not found"]);
|
||||
exit;
|
||||
}
|
||||
if ($order === 0) {
|
||||
$max = $pdo->prepare("SELECT COALESCE(MAX(orderIndex),0)+1 FROM answer_option WHERE questionID = :id");
|
||||
$max->execute([':id' => $qID]);
|
||||
$order = (int)$max->fetchColumn();
|
||||
}
|
||||
$pdo->prepare("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
|
||||
VALUES (:id, :qid, :t, :p, :o, :nq)")
|
||||
->execute([':id' => $id, ':qid' => $qID, ':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "answerOption" => [
|
||||
"answerOptionID" => $id, "questionID" => $qID, "defaultText" => $text,
|
||||
"points" => $points, "orderIndex" => $order, "nextQuestionId" => $nextQ, "translations" => []
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['answerOptionID'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "answerOptionID is required"]);
|
||||
exit;
|
||||
}
|
||||
$id = $body['answerOptionID'];
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$existing = $pdo->prepare("SELECT * FROM answer_option WHERE answerOptionID = :id");
|
||||
$existing->execute([':id' => $id]);
|
||||
$row = $existing->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Answer option not found"]);
|
||||
exit;
|
||||
}
|
||||
$text = trim($body['defaultText'] ?? $row['defaultText']);
|
||||
$points = (int)($body['points'] ?? $row['points']);
|
||||
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
||||
$nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']);
|
||||
|
||||
$pdo->prepare("UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id")
|
||||
->execute([':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "answerOption" => [
|
||||
"answerOptionID" => $id, "questionID" => $row['questionID'],
|
||||
"defaultText" => $text, "points" => $points, "orderIndex" => $order,
|
||||
"nextQuestionId" => $nextQ
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['answerOptionID'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "answerOptionID is required"]);
|
||||
exit;
|
||||
}
|
||||
$id = $body['answerOptionID'];
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$pdo->exec("PRAGMA foreign_keys = OFF;");
|
||||
$pdo->beginTransaction();
|
||||
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $id]);
|
||||
$pdo->prepare("UPDATE client_answer SET answerOptionID = NULL WHERE answerOptionID = :id")->execute([':id' => $id]);
|
||||
$pdo->prepare("DELETE FROM answer_option WHERE answerOptionID = :id")->execute([':id' => $id]);
|
||||
$pdo->commit();
|
||||
$pdo->exec("PRAGMA foreign_keys = ON;");
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PATCH':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['questionID']) || !is_array($body['order'] ?? null)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionID and order[] required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$stmt = $pdo->prepare("UPDATE answer_option SET orderIndex = :o WHERE answerOptionID = :id AND questionID = :qid");
|
||||
foreach ($body['order'] as $idx => $aoid) {
|
||||
$stmt->execute([':o' => $idx, ':id' => $aoid, ':qid' => $body['questionID']]);
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
}
|
||||
174
api/app_questionnaires.php
Normal file
174
api/app_questionnaires.php
Normal file
@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/**
|
||||
* Android app API endpoint.
|
||||
*
|
||||
* GET (no params) -> ordered questionnaire list with conditions
|
||||
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format
|
||||
* GET ?translations=1 -> all translations merged across all tables
|
||||
*/
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
$qnID = $_GET['id'] ?? '';
|
||||
$translations = $_GET['translations'] ?? '';
|
||||
|
||||
if ($translations) {
|
||||
// Return all translations merged from all three tables, keyed by language
|
||||
$result = [];
|
||||
|
||||
// question translations: map questionID -> defaultText (the key), then lang -> text
|
||||
$rows = $pdo->query("
|
||||
SELECT q.defaultText AS stringKey, qt.languageCode, qt.text
|
||||
FROM question_translation qt
|
||||
JOIN question q ON q.questionID = qt.questionID
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $r) {
|
||||
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
|
||||
}
|
||||
|
||||
// answer option translations: map answerOptionID -> defaultText (the key)
|
||||
$rows = $pdo->query("
|
||||
SELECT ao.defaultText AS stringKey, aot.languageCode, aot.text
|
||||
FROM answer_option_translation aot
|
||||
JOIN answer_option ao ON ao.answerOptionID = aot.answerOptionID
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $r) {
|
||||
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
|
||||
}
|
||||
|
||||
// string translations (symptoms, hints, textKeys, etc.)
|
||||
$rows = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $r) {
|
||||
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "translations" => $result]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($qnID) {
|
||||
// Single questionnaire in app JSON format
|
||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||
$qn->execute([':id' => $qnID]);
|
||||
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$qnRow) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Questionnaire not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT questionID, defaultText, type, orderIndex, configJson
|
||||
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
|
||||
");
|
||||
$stmt->execute([':id' => $qnID]);
|
||||
$dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$questions = [];
|
||||
foreach ($dbQuestions as $dbQ) {
|
||||
$localId = $dbQ['questionID'];
|
||||
$parts = explode('__', $localId);
|
||||
$shortId = end($parts);
|
||||
|
||||
$layout = $dbQ['type'];
|
||||
$config = json_decode($dbQ['configJson'], true) ?: [];
|
||||
|
||||
$q = [
|
||||
'id' => $shortId,
|
||||
'layout' => $layout,
|
||||
'question' => $dbQ['defaultText'],
|
||||
];
|
||||
|
||||
// Add type-specific config fields back to the top level
|
||||
if (isset($config['textKey'])) $q['textKey'] = $config['textKey'];
|
||||
if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1'];
|
||||
if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2'];
|
||||
if (isset($config['hint'])) $q['hint'] = $config['hint'];
|
||||
if (isset($config['hint1'])) $q['hint1'] = $config['hint1'];
|
||||
if (isset($config['hint2'])) $q['hint2'] = $config['hint2'];
|
||||
if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms'];
|
||||
if (isset($config['range'])) $q['range'] = $config['range'];
|
||||
if (isset($config['constraints'])) $q['constraints'] = $config['constraints'];
|
||||
if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection'];
|
||||
|
||||
// String spinner static options
|
||||
if ($layout === 'string_spinner' && isset($config['options'])) {
|
||||
$q['options'] = $config['options'];
|
||||
}
|
||||
|
||||
// Value spinner conditional navigation options
|
||||
if ($layout === 'value_spinner' && isset($config['valueOptions'])) {
|
||||
$q['options'] = $config['valueOptions'];
|
||||
}
|
||||
|
||||
// Answer options (for radio_question, multi_check_box_question, etc.)
|
||||
$ao = $pdo->prepare("
|
||||
SELECT defaultText, points, nextQuestionId
|
||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
||||
");
|
||||
$ao->execute([':qid' => $dbQ['questionID']]);
|
||||
$opts = $ao->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($opts) {
|
||||
$optionsArr = [];
|
||||
$pointsMap = [];
|
||||
foreach ($opts as $opt) {
|
||||
$o = ['key' => $opt['defaultText']];
|
||||
if ($opt['nextQuestionId'] !== '') {
|
||||
$o['nextQuestionId'] = $opt['nextQuestionId'];
|
||||
}
|
||||
$optionsArr[] = $o;
|
||||
$pointsMap[$opt['defaultText']] = (int)$opt['points'];
|
||||
}
|
||||
$q['options'] = $optionsArr;
|
||||
|
||||
$hasNonZero = false;
|
||||
foreach ($pointsMap as $v) { if ($v !== 0) { $hasNonZero = true; break; } }
|
||||
$q['pointsMap'] = $pointsMap;
|
||||
}
|
||||
|
||||
$questions[] = $q;
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode([
|
||||
"meta" => ["id" => $qnID],
|
||||
"questions" => $questions,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Default: ordered questionnaire list
|
||||
$rows = $pdo->query("
|
||||
SELECT questionnaireID, name, showPoints, conditionJson
|
||||
FROM questionnaire
|
||||
WHERE state = 'active'
|
||||
ORDER BY orderIndex
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $r) {
|
||||
$list[] = [
|
||||
'id' => $r['questionnaireID'],
|
||||
'name' => $r['name'],
|
||||
'showPoints' => (bool)(int)$r['showPoints'],
|
||||
'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(),
|
||||
];
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode($list);
|
||||
117
api/assignments.php
Normal file
117
api/assignments.php
Normal file
@ -0,0 +1,117 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
|
||||
$callerRole = $tokenRec['role'];
|
||||
$callerEntityID = $tokenRec['entityID'] ?? '';
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// ── GET: coaches + clients scoped by RBAC ────────────────────────────────
|
||||
if ($method === 'GET') {
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
if ($callerRole === 'admin') {
|
||||
$coaches = $pdo->query(
|
||||
"SELECT c.coachID, c.username, c.supervisorID, sv.username AS supervisorUsername
|
||||
FROM coach c
|
||||
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
|
||||
ORDER BY sv.username, c.username"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT c.coachID, c.username, c.supervisorID, sv.username AS supervisorUsername
|
||||
FROM coach c
|
||||
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
|
||||
WHERE c.supervisorID = :sid ORDER BY c.username"
|
||||
);
|
||||
$stmt->execute([':sid' => $callerEntityID]);
|
||||
$coaches = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
[$clause, $params] = rbac_client_filter($tokenRec);
|
||||
$clientStmt = $pdo->prepare(
|
||||
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
|
||||
FROM client cl
|
||||
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||
WHERE $clause
|
||||
ORDER BY cl.coachID, cl.clientCode"
|
||||
);
|
||||
foreach ($params as $k => $v) $clientStmt->bindValue($k, $v);
|
||||
$clientStmt->execute();
|
||||
$clients = $clientStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "coaches" => $coaches, "clients" => $clients]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── POST: assign clients to a coach ──────────────────────────────────────
|
||||
if ($method === 'POST') {
|
||||
$in = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$clientCodes = $in['clientCodes'] ?? [];
|
||||
$coachID = trim($in['coachID'] ?? '');
|
||||
|
||||
if (empty($clientCodes) || $coachID === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "clientCodes and coachID required"]);
|
||||
exit;
|
||||
}
|
||||
if (!is_array($clientCodes)) $clientCodes = [$clientCodes];
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
|
||||
if ($callerRole === 'supervisor') {
|
||||
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
|
||||
$chk->execute([':cid' => $coachID, ':sid' => $callerEntityID]);
|
||||
} else {
|
||||
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
|
||||
$chk->execute([':cid' => $coachID]);
|
||||
}
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Coach not found or not authorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Only allow reassignment of clients the caller can already see
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
|
||||
$pdo->beginTransaction();
|
||||
$updStmt = $pdo->prepare(
|
||||
"UPDATE client SET coachID = :cid WHERE clientCode = :cc AND ($rbacClause)"
|
||||
);
|
||||
$count = 0;
|
||||
foreach ($clientCodes as $cc) {
|
||||
$cc = trim($cc);
|
||||
if ($cc === '') continue;
|
||||
$updStmt->execute(array_merge([':cid' => $coachID, ':cc' => $cc], $rbacParams));
|
||||
$count += $updStmt->rowCount();
|
||||
}
|
||||
$pdo->commit();
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
echo json_encode(["success" => true, "assigned" => $count]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
138
api/export.php
Normal file
138
api/export.php
Normal file
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$qnID = $_GET['questionnaireID'] ?? '';
|
||||
$format = strtolower($_GET['format'] ?? 'csv');
|
||||
|
||||
if (!$qnID) {
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionnaireID query param required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
// Questionnaire metadata
|
||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||
$qn->execute([':id' => $qnID]);
|
||||
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$questionnaire) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Questionnaire not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Questions ordered
|
||||
$qStmt = $pdo->prepare("SELECT questionID, defaultText, orderIndex FROM question WHERE questionnaireID = :id ORDER BY orderIndex");
|
||||
$qStmt->execute([':id' => $qnID]);
|
||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$questionIDs = array_column($questions, 'questionID');
|
||||
|
||||
// Build answer-option lookup for resolving display text
|
||||
$optionTextMap = [];
|
||||
foreach ($questionIDs as $qid) {
|
||||
$aoStmt = $pdo->prepare("SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid");
|
||||
$aoStmt->execute([':qid' => $qid]);
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionTextMap[$ao['answerOptionID']] = $ao['defaultText'];
|
||||
}
|
||||
}
|
||||
|
||||
// RBAC filter
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
|
||||
$sql = "
|
||||
SELECT cl.clientCode, cl.coachID,
|
||||
cq.status, cq.sumPoints, cq.startedAt, cq.completedAt
|
||||
FROM client cl
|
||||
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
|
||||
WHERE $rbacClause
|
||||
ORDER BY cl.clientCode
|
||||
";
|
||||
$params = array_merge([':qnid' => $qnID], $rbacParams);
|
||||
$cStmt = $pdo->prepare($sql);
|
||||
$cStmt->execute($params);
|
||||
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Fetch answers for each client
|
||||
$qPlaceholders = !empty($questionIDs) ? implode(',', array_fill(0, count($questionIDs), '?')) : "'__none__'";
|
||||
$answerStmt = $pdo->prepare("
|
||||
SELECT questionID, answerOptionID, freeTextValue, numericValue
|
||||
FROM client_answer
|
||||
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
|
||||
");
|
||||
|
||||
$rows = [];
|
||||
foreach ($clients as $c) {
|
||||
$row = [
|
||||
'clientCode' => $c['clientCode'],
|
||||
'coachID' => $c['coachID'],
|
||||
'status' => $c['status'],
|
||||
'sumPoints' => $c['sumPoints'],
|
||||
'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '',
|
||||
'completedAt'=> $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '',
|
||||
];
|
||||
|
||||
$bindParams = array_merge([$c['clientCode']], $questionIDs);
|
||||
$answerStmt->execute($bindParams);
|
||||
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$answerMap = [];
|
||||
foreach ($answers as $a) $answerMap[$a['questionID']] = $a;
|
||||
|
||||
foreach ($questions as $q) {
|
||||
$qid = $q['questionID'];
|
||||
if (isset($answerMap[$qid])) {
|
||||
$a = $answerMap[$qid];
|
||||
if ($a['answerOptionID'] && isset($optionTextMap[$a['answerOptionID']])) {
|
||||
$row[$q['defaultText']] = $optionTextMap[$a['answerOptionID']];
|
||||
} elseif ($a['freeTextValue'] !== null && $a['freeTextValue'] !== '') {
|
||||
$row[$q['defaultText']] = $a['freeTextValue'];
|
||||
} elseif ($a['numericValue'] !== null) {
|
||||
$row[$q['defaultText']] = $a['numericValue'];
|
||||
} else {
|
||||
$row[$q['defaultText']] = '';
|
||||
}
|
||||
} else {
|
||||
$row[$q['defaultText']] = '';
|
||||
}
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
// Output CSV
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $questionnaire['name']);
|
||||
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
|
||||
|
||||
header('Content-Type: text/csv; charset=UTF-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
// BOM for Excel UTF-8 recognition
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
|
||||
if (!empty($rows)) {
|
||||
fputcsv($out, array_keys($rows[0]));
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($out, array_values($row));
|
||||
}
|
||||
} else {
|
||||
$header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt'];
|
||||
foreach ($questions as $q) $header[] = $q['defaultText'];
|
||||
fputcsv($out, $header);
|
||||
}
|
||||
fclose($out);
|
||||
66
api/index.php
Normal file
66
api/index.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
/**
|
||||
* Front-controller: single entry point for all /api/ requests.
|
||||
* Handles CORS, JSON content type, global error catching, and dispatch.
|
||||
*/
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
require_once __DIR__ . '/../lib/response.php';
|
||||
require_once __DIR__ . '/../lib/validate.php';
|
||||
|
||||
// CORS
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
// Parse route: strip /api/ prefix and .php suffix, normalize
|
||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = parse_url($requestUri, PHP_URL_PATH);
|
||||
$base = dirname($_SERVER['SCRIPT_NAME']);
|
||||
$route = trim(substr($path, strlen($base)), '/');
|
||||
$route = preg_replace('/\.php$/', '', $route);
|
||||
$route = strtolower($route);
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// Dispatch table: route -> handler file
|
||||
$routes = [
|
||||
'questionnaires' => __DIR__ . '/../handlers/questionnaires.php',
|
||||
'questions' => __DIR__ . '/../handlers/questions.php',
|
||||
'answer_options' => __DIR__ . '/../handlers/answer_options.php',
|
||||
'translations' => __DIR__ . '/../handlers/translations.php',
|
||||
'users' => __DIR__ . '/../handlers/users.php',
|
||||
'assignments' => __DIR__ . '/../handlers/assignments.php',
|
||||
'results' => __DIR__ . '/../handlers/results.php',
|
||||
'export' => __DIR__ . '/../handlers/export.php',
|
||||
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
|
||||
'logout' => __DIR__ . '/../handlers/logout.php',
|
||||
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
||||
'auth/change-password' => __DIR__ . '/../handlers/auth.php',
|
||||
'backup' => __DIR__ . '/../handlers/backup.php',
|
||||
'download' => __DIR__ . '/../handlers/download.php',
|
||||
];
|
||||
|
||||
try {
|
||||
if (!isset($routes[$route])) {
|
||||
json_error('NOT_FOUND', "Unknown endpoint: $route", 404);
|
||||
}
|
||||
|
||||
$handlerFile = $routes[$route];
|
||||
if (!file_exists($handlerFile)) {
|
||||
json_error('NOT_FOUND', "Handler not found for: $route", 404);
|
||||
}
|
||||
|
||||
require $handlerFile;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log("Unhandled error on $method $route: " . $e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Internal server error', 500);
|
||||
}
|
||||
21
api/logout.php
Normal file
21
api/logout.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'DELETE' && $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = get_bearer_token();
|
||||
if (!$token) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Missing Bearer token"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
token_revoke($token);
|
||||
echo json_encode(["success" => true]);
|
||||
169
api/questionnaires.php
Normal file
169
api/questionnaires.php
Normal file
@ -0,0 +1,169 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$rows = $pdo->query("
|
||||
SELECT q.questionnaireID, q.name, q.version, q.state,
|
||||
q.orderIndex, q.showPoints, q.conditionJson,
|
||||
COUNT(qu.questionID) AS questionCount
|
||||
FROM questionnaire q
|
||||
LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID
|
||||
GROUP BY q.questionnaireID
|
||||
ORDER BY q.orderIndex, q.name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as &$r) {
|
||||
$r['orderIndex'] = (int)$r['orderIndex'];
|
||||
$r['showPoints'] = (int)$r['showPoints'];
|
||||
$r['conditionJson'] = $r['conditionJson'] ?: '{}';
|
||||
}
|
||||
unset($r);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "questionnaires" => $rows]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['name'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "name is required"]);
|
||||
exit;
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$name = trim($body['name']);
|
||||
$version = trim($body['version'] ?? '');
|
||||
$state = trim($body['state'] ?? 'draft');
|
||||
$order = (int)($body['orderIndex'] ?? 0);
|
||||
$showPts = (int)($body['showPoints'] ?? 0);
|
||||
$condJson = $body['conditionJson'] ?? '{}';
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
if ($order === 0) {
|
||||
$order = (int)$pdo->query("SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire")->fetchColumn();
|
||||
}
|
||||
$pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson)
|
||||
VALUES (:id, :n, :v, :s, :o, :sp, :cj)")
|
||||
->execute([':id' => $id, ':n' => $name, ':v' => $version, ':s' => $state,
|
||||
':o' => $order, ':sp' => $showPts, ':cj' => $condJson]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "questionnaire" => [
|
||||
"questionnaireID" => $id, "name" => $name, "version" => $version,
|
||||
"state" => $state, "orderIndex" => $order, "showPoints" => $showPts,
|
||||
"conditionJson" => $condJson, "questionCount" => 0
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['questionnaireID'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionnaireID is required"]);
|
||||
exit;
|
||||
}
|
||||
$id = $body['questionnaireID'];
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$existing = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||
$existing->execute([':id' => $id]);
|
||||
$row = $existing->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Questionnaire not found"]);
|
||||
exit;
|
||||
}
|
||||
$name = trim($body['name'] ?? $row['name']);
|
||||
$version = trim($body['version'] ?? $row['version']);
|
||||
$state = trim($body['state'] ?? $row['state']);
|
||||
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
||||
$showPts = (int)($body['showPoints'] ?? $row['showPoints']);
|
||||
$condJson = $body['conditionJson'] ?? $row['conditionJson'];
|
||||
|
||||
$pdo->prepare("UPDATE questionnaire SET name = :n, version = :v, state = :s,
|
||||
orderIndex = :o, showPoints = :sp, conditionJson = :cj
|
||||
WHERE questionnaireID = :id")
|
||||
->execute([':n' => $name, ':v' => $version, ':s' => $state,
|
||||
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':id' => $id]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "questionnaire" => [
|
||||
"questionnaireID" => $id, "name" => $name, "version" => $version, "state" => $state,
|
||||
"orderIndex" => $order, "showPoints" => $showPts, "conditionJson" => $condJson
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
require_role(['admin'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['questionnaireID'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionnaireID is required"]);
|
||||
exit;
|
||||
}
|
||||
$id = $body['questionnaireID'];
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$pdo->exec("PRAGMA foreign_keys = OFF;");
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// Get question IDs to cascade-delete answer options and translations
|
||||
$qIds = $pdo->prepare("SELECT questionID FROM question WHERE questionnaireID = :id");
|
||||
$qIds->execute([':id' => $id]);
|
||||
$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]);
|
||||
$optionIDs = $aoIds->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($optionIDs 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' => $id]);
|
||||
$pdo->prepare("DELETE FROM completed_questionnaire WHERE questionnaireID = :id")->execute([':id' => $id]);
|
||||
$pdo->prepare("DELETE FROM questionnaire WHERE questionnaireID = :id")->execute([':id' => $id]);
|
||||
|
||||
$pdo->commit();
|
||||
$pdo->exec("PRAGMA foreign_keys = ON;");
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
}
|
||||
215
api/questions.php
Normal file
215
api/questions.php
Normal file
@ -0,0 +1,215 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
$qnID = $_GET['questionnaireID'] ?? '';
|
||||
if (!$qnID) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionnaireID query param required"]);
|
||||
exit;
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson
|
||||
FROM question WHERE questionnaireID = :qid ORDER BY orderIndex
|
||||
");
|
||||
$stmt->execute([':qid' => $qnID]);
|
||||
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($questions as &$q) {
|
||||
$q['isRequired'] = (int)$q['isRequired'];
|
||||
$q['orderIndex'] = (int)$q['orderIndex'];
|
||||
$q['configJson'] = $q['configJson'] ?: '{}';
|
||||
$ao = $pdo->prepare("
|
||||
SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId
|
||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
||||
");
|
||||
$ao->execute([':qid' => $q['questionID']]);
|
||||
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($q['answerOptions'] as &$opt) {
|
||||
$opt['points'] = (int)$opt['points'];
|
||||
$opt['orderIndex'] = (int)$opt['orderIndex'];
|
||||
}
|
||||
|
||||
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
|
||||
$tr->execute([':qid' => $q['questionID']]);
|
||||
$q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
unset($q);
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "questions" => $questions]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['questionnaireID']) || !isset($body['defaultText'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionnaireID and defaultText required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$qnID = $body['questionnaireID'];
|
||||
$text = trim($body['defaultText']);
|
||||
$type = trim($body['type'] ?? '');
|
||||
$order = (int)($body['orderIndex'] ?? 0);
|
||||
$req = (int)($body['isRequired'] ?? 0);
|
||||
$config = $body['configJson'] ?? '{}';
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$chk = $pdo->prepare("SELECT 1 FROM questionnaire WHERE questionnaireID = :id");
|
||||
$chk->execute([':id' => $qnID]);
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Questionnaire not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($order === 0) {
|
||||
$max = $pdo->prepare("SELECT COALESCE(MAX(orderIndex),0)+1 FROM question WHERE questionnaireID = :id");
|
||||
$max->execute([':id' => $qnID]);
|
||||
$order = (int)$max->fetchColumn();
|
||||
}
|
||||
|
||||
$pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
|
||||
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)")
|
||||
->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type,
|
||||
':o' => $order, ':r' => $req, ':cj' => $config]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "question" => [
|
||||
"questionID" => $id, "questionnaireID" => $qnID, "defaultText" => $text,
|
||||
"type" => $type, "orderIndex" => $order, "isRequired" => $req,
|
||||
"configJson" => $config, "answerOptions" => [], "translations" => []
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['questionID'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionID is required"]);
|
||||
exit;
|
||||
}
|
||||
$id = $body['questionID'];
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$existing = $pdo->prepare("SELECT * FROM question WHERE questionID = :id");
|
||||
$existing->execute([':id' => $id]);
|
||||
$row = $existing->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Question not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$text = trim($body['defaultText'] ?? $row['defaultText']);
|
||||
$type = trim($body['type'] ?? $row['type']);
|
||||
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
|
||||
$req = (int)($body['isRequired'] ?? $row['isRequired']);
|
||||
$config = $body['configJson'] ?? $row['configJson'];
|
||||
|
||||
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id")
|
||||
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $config, ':id' => $id]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "question" => [
|
||||
"questionID" => $id, "questionnaireID" => $row['questionnaireID'],
|
||||
"defaultText" => $text, "type" => $type, "orderIndex" => $order, "isRequired" => $req,
|
||||
"configJson" => $config
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['questionID'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionID is required"]);
|
||||
exit;
|
||||
}
|
||||
$id = $body['questionID'];
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$pdo->exec("PRAGMA foreign_keys = OFF;");
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$aoIds = $pdo->prepare("SELECT answerOptionID FROM answer_option WHERE questionID = :id");
|
||||
$aoIds->execute([':id' => $id]);
|
||||
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 = :id")->execute([':id' => $id]);
|
||||
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id")->execute([':id' => $id]);
|
||||
$pdo->prepare("DELETE FROM client_answer WHERE questionID = :id")->execute([':id' => $id]);
|
||||
$pdo->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]);
|
||||
|
||||
$pdo->commit();
|
||||
$pdo->exec("PRAGMA foreign_keys = ON;");
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PATCH':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body || empty($body['questionnaireID']) || !is_array($body['order'] ?? null)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionnaireID and order[] required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$stmt = $pdo->prepare("UPDATE question SET orderIndex = :o WHERE questionID = :id AND questionnaireID = :qid");
|
||||
foreach ($body['order'] as $idx => $qid) {
|
||||
$stmt->execute([':o' => $idx, ':id' => $qid, ':qid' => $body['questionnaireID']]);
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
}
|
||||
139
api/results.php
Normal file
139
api/results.php
Normal file
@ -0,0 +1,139 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$qnID = $_GET['questionnaireID'] ?? '';
|
||||
$clientCode = $_GET['clientCode'] ?? '';
|
||||
|
||||
if (!$qnID) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "questionnaireID query param required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
// Questionnaire metadata
|
||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||
$qn->execute([':id' => $qnID]);
|
||||
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$questionnaire) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "Questionnaire not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Questions with answer options
|
||||
$qStmt = $pdo->prepare("
|
||||
SELECT questionID, defaultText, type, orderIndex, isRequired
|
||||
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
|
||||
");
|
||||
$qStmt->execute([':id' => $qnID]);
|
||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$questionIDs = array_column($questions, 'questionID');
|
||||
|
||||
foreach ($questions as &$q) {
|
||||
$q['isRequired'] = (int)$q['isRequired'];
|
||||
$q['orderIndex'] = (int)$q['orderIndex'];
|
||||
$ao = $pdo->prepare("SELECT answerOptionID, defaultText, points, orderIndex
|
||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex");
|
||||
$ao->execute([':qid' => $q['questionID']]);
|
||||
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($q['answerOptions'] as &$opt) {
|
||||
$opt['points'] = (int)$opt['points'];
|
||||
$opt['orderIndex'] = (int)$opt['orderIndex'];
|
||||
}
|
||||
}
|
||||
unset($q);
|
||||
|
||||
// RBAC filter
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
|
||||
// Build client list
|
||||
if ($clientCode) {
|
||||
$clientSQL = "SELECT cl.clientCode, cl.coachID FROM client cl WHERE cl.clientCode = :cc AND $rbacClause";
|
||||
$clientParams = array_merge([':cc' => $clientCode], $rbacParams);
|
||||
} else {
|
||||
$clientSQL = "SELECT cl.clientCode, cl.coachID FROM client cl WHERE $rbacClause";
|
||||
$clientParams = $rbacParams;
|
||||
}
|
||||
|
||||
// Only clients that have a completed_questionnaire entry for this questionnaire
|
||||
$sql = "
|
||||
SELECT cl.clientCode, cl.coachID,
|
||||
cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach
|
||||
FROM client cl
|
||||
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
|
||||
WHERE $rbacClause
|
||||
";
|
||||
$params = array_merge([':qnid' => $qnID], $rbacParams);
|
||||
|
||||
if ($clientCode) {
|
||||
$sql .= " AND cl.clientCode = :cc";
|
||||
$params[':cc'] = $clientCode;
|
||||
}
|
||||
$sql .= " ORDER BY cl.clientCode";
|
||||
|
||||
$cStmt = $pdo->prepare($sql);
|
||||
$cStmt->execute($params);
|
||||
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Build question ID placeholders for answer fetch
|
||||
if (!empty($questionIDs) && !empty($clients)) {
|
||||
$qPlaceholders = implode(',', array_fill(0, count($questionIDs), '?'));
|
||||
$answerStmt = $pdo->prepare("
|
||||
SELECT clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
|
||||
FROM client_answer
|
||||
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
|
||||
");
|
||||
|
||||
foreach ($clients as &$c) {
|
||||
$c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null;
|
||||
$c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null;
|
||||
$c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : null;
|
||||
|
||||
$bindParams = array_merge([$c['clientCode']], $questionIDs);
|
||||
$answerStmt->execute($bindParams);
|
||||
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$answerMap = [];
|
||||
foreach ($answers as $a) {
|
||||
$answerMap[$a['questionID']] = [
|
||||
'answerOptionID' => $a['answerOptionID'],
|
||||
'freeTextValue' => $a['freeTextValue'],
|
||||
'numericValue' => $a['numericValue'] !== null ? (float)$a['numericValue'] : null,
|
||||
'answeredAt' => $a['answeredAt'] !== null ? (int)$a['answeredAt'] : null,
|
||||
];
|
||||
}
|
||||
$c['answers'] = $answerMap;
|
||||
}
|
||||
unset($c);
|
||||
} else {
|
||||
foreach ($clients as &$c) {
|
||||
$c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null;
|
||||
$c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null;
|
||||
$c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : null;
|
||||
$c['answers'] = (object)[];
|
||||
}
|
||||
unset($c);
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"questionnaire" => $questionnaire,
|
||||
"questions" => $questions,
|
||||
"clients" => $clients
|
||||
]);
|
||||
291
api/translations.php
Normal file
291
api/translations.php
Normal file
@ -0,0 +1,291 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
$validTypes = ['question', 'answer_option', 'string', 'language'];
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
// GET ?languages=1 -> list all languages
|
||||
if (isset($_GET['languages'])) {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "languages" => $rows]);
|
||||
break;
|
||||
}
|
||||
|
||||
// GET ?questionnaireID=X -> batch: all keys + all translations for a questionnaire
|
||||
$qnID = $_GET['questionnaireID'] ?? '';
|
||||
if ($qnID) {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Question keys + translations
|
||||
$qStmt = $pdo->prepare("
|
||||
SELECT q.questionID, q.defaultText, q.configJson
|
||||
FROM question q WHERE q.questionnaireID = :id ORDER BY q.orderIndex
|
||||
");
|
||||
$qStmt->execute([':id' => $qnID]);
|
||||
$dbQuestions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$entries = [];
|
||||
$stringKeys = [];
|
||||
|
||||
foreach ($dbQuestions as $dbQ) {
|
||||
$entries[] = [
|
||||
'key' => $dbQ['defaultText'],
|
||||
'type' => 'question',
|
||||
'entityId' => $dbQ['questionID'],
|
||||
];
|
||||
|
||||
// Answer option keys
|
||||
$aoStmt = $pdo->prepare("
|
||||
SELECT answerOptionID, defaultText FROM answer_option
|
||||
WHERE questionID = :qid ORDER BY orderIndex
|
||||
");
|
||||
$aoStmt->execute([':qid' => $dbQ['questionID']]);
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$entries[] = [
|
||||
'key' => $ao['defaultText'],
|
||||
'type' => 'answer_option',
|
||||
'entityId' => $ao['answerOptionID'],
|
||||
];
|
||||
}
|
||||
|
||||
// String keys from configJson
|
||||
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
||||
foreach (['textKey','textKey1','textKey2','hint','hint1','hint2'] 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;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($stringKeys) as $sk) {
|
||||
$entries[] = [
|
||||
'key' => $sk,
|
||||
'type' => 'string',
|
||||
'entityId' => $sk,
|
||||
];
|
||||
}
|
||||
|
||||
// Fetch all existing translations for these entities
|
||||
$translations = [];
|
||||
|
||||
// question translations
|
||||
$qIds = 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(array_values($qIds));
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
||||
}
|
||||
}
|
||||
|
||||
// answer_option translations
|
||||
$aoIds = 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(array_values($aoIds));
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
||||
}
|
||||
}
|
||||
|
||||
// string translations
|
||||
$sKeys = array_filter(array_map(fn($e) => $e['type'] === '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(array_values($sKeys));
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
||||
}
|
||||
}
|
||||
|
||||
// Attach translations to entries
|
||||
foreach ($entries as &$e) {
|
||||
$e['translations'] = $translations[$e['entityId']] ?? (object)[];
|
||||
}
|
||||
unset($e);
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"languages" => $languages,
|
||||
"entries" => $entries,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
// Original single-entity fetch
|
||||
$type = $_GET['type'] ?? '';
|
||||
$id = $_GET['id'] ?? '';
|
||||
if (!in_array($type, $validTypes, true) || (!$id && $type !== 'string')) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "type (question|answer_option|string) and id required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
if ($type === 'question') {
|
||||
$stmt = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :id");
|
||||
$stmt->execute([':id' => $id]);
|
||||
} elseif ($type === 'answer_option') {
|
||||
$stmt = $pdo->prepare("SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id");
|
||||
$stmt->execute([':id' => $id]);
|
||||
} else {
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare("SELECT stringKey, languageCode, text FROM string_translation WHERE stringKey = :id");
|
||||
$stmt->execute([':id' => $id]);
|
||||
} else {
|
||||
$stmt = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation ORDER BY stringKey, languageCode");
|
||||
}
|
||||
}
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true, "translations" => $rows]);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$type = $body['type'] ?? '';
|
||||
|
||||
// Language management
|
||||
if ($type === 'language') {
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
$name = trim($body['name'] ?? '');
|
||||
if (!$lang) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "languageCode required"]);
|
||||
exit;
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
|
||||
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name")
|
||||
->execute([':lc' => $lang, ':n' => $name]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$id = $body['id'] ?? '';
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
$text = $body['text'] ?? '';
|
||||
if (!in_array($type, $validTypes, true) || !$id || !$lang) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "type, id, and languageCode required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
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]);
|
||||
} 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]);
|
||||
} else {
|
||||
$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]);
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$type = $body['type'] ?? '';
|
||||
|
||||
// Language deletion
|
||||
if ($type === 'language') {
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
if (!$lang) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "languageCode required"]);
|
||||
exit;
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
$pdo->prepare("DELETE FROM language WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||
$pdo->prepare("DELETE FROM question_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||
$pdo->prepare("DELETE FROM answer_option_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||
$pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$id = $body['id'] ?? '';
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
if (!in_array($type, $validTypes, true) || !$id || !$lang) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "type, id, and languageCode required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
if ($type === 'question') {
|
||||
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lang")
|
||||
->execute([':id' => $id, ':lang' => $lang]);
|
||||
} elseif ($type === 'answer_option') {
|
||||
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id AND languageCode = :lang")
|
||||
->execute([':id' => $id, ':lang' => $lang]);
|
||||
} else {
|
||||
$pdo->prepare("DELETE FROM string_translation WHERE stringKey = :id AND languageCode = :lang")
|
||||
->execute([':id' => $id, ':lang' => $lang]);
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
}
|
||||
275
api/users.php
Normal file
275
api/users.php
Normal file
@ -0,0 +1,275 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../common.php';
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
|
||||
$callerRole = $tokenRec['role'];
|
||||
$callerEntityID = $tokenRec['entityID'] ?? '';
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// ── GET: list users (+ supervisors for admin) ─────────────────────────────
|
||||
if ($method === 'GET') {
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
|
||||
if ($callerRole === 'admin') {
|
||||
$users = $pdo->query(
|
||||
"SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
|
||||
COALESCE(a.location, s.location, '') AS location,
|
||||
c.supervisorID,
|
||||
sv.username AS supervisorUsername
|
||||
FROM users u
|
||||
LEFT JOIN admin a ON a.adminID = u.entityID AND u.role = 'admin'
|
||||
LEFT JOIN supervisor s ON s.supervisorID = u.entityID AND u.role = 'supervisor'
|
||||
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
|
||||
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
|
||||
ORDER BY u.role, u.username"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$supervisors = $pdo->query(
|
||||
"SELECT supervisorID, username, location FROM supervisor ORDER BY username"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} else {
|
||||
// Supervisor: only their coaches
|
||||
$svNameStmt = $pdo->prepare("SELECT username FROM supervisor WHERE supervisorID = :svid");
|
||||
$svNameStmt->execute([':svid' => $callerEntityID]);
|
||||
$svName = $svNameStmt->fetchColumn() ?: '';
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
|
||||
'' AS location, c.supervisorID, :svname AS supervisorUsername
|
||||
FROM users u
|
||||
JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
|
||||
WHERE c.supervisorID = :svid
|
||||
ORDER BY u.username"
|
||||
);
|
||||
$stmt->execute([':svid' => $callerEntityID, ':svname' => $svName]);
|
||||
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$supervisors = [];
|
||||
}
|
||||
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"users" => $users,
|
||||
"supervisors" => $supervisors,
|
||||
"callerRole" => $callerRole,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── POST: create a user ───────────────────────────────────────────────────
|
||||
if ($method === 'POST') {
|
||||
$in = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$username = trim($in['username'] ?? '');
|
||||
$password = (string)($in['password'] ?? '');
|
||||
$role = strtolower(trim($in['role'] ?? ''));
|
||||
$location = trim($in['location'] ?? '');
|
||||
$supervisorID = trim($in['supervisorID'] ?? '');
|
||||
$mustChange = isset($in['mustChangePassword']) ? (int)(bool)$in['mustChangePassword'] : 1;
|
||||
|
||||
if ($username === '' || $password === '' || $role === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "username, password, and role are required"]);
|
||||
exit;
|
||||
}
|
||||
if ($callerRole === 'supervisor' && $role !== 'coach') {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Supervisors may only create coaches"]);
|
||||
exit;
|
||||
}
|
||||
if (!in_array($role, ['admin', 'supervisor', 'coach'], true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "role must be admin, supervisor, or coach"]);
|
||||
exit;
|
||||
}
|
||||
if (strlen($password) < 6) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Password must be at least 6 characters"]);
|
||||
exit;
|
||||
}
|
||||
// Supervisors always create coaches under themselves
|
||||
if ($callerRole === 'supervisor') {
|
||||
$supervisorID = $callerEntityID;
|
||||
}
|
||||
if ($role === 'coach' && $supervisorID === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "supervisorID is required for coaches"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
|
||||
$chk = $pdo->prepare("SELECT userID FROM users WHERE username = :u");
|
||||
$chk->execute([':u' => $username]);
|
||||
if ($chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(409);
|
||||
echo json_encode(["error" => "Username already exists"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($role === 'coach') {
|
||||
$svCheck = $pdo->prepare("SELECT supervisorID FROM supervisor WHERE supervisorID = :sid");
|
||||
$svCheck->execute([':sid' => $supervisorID]);
|
||||
if (!$svCheck->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Supervisor not found"]);
|
||||
exit;
|
||||
}
|
||||
if ($callerRole === 'supervisor' && $supervisorID !== $callerEntityID) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Cannot create coaches for another supervisor"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$entityID = bin2hex(random_bytes(16));
|
||||
$userID = bin2hex(random_bytes(16));
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$now = time();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
switch ($role) {
|
||||
case 'admin':
|
||||
$pdo->prepare("INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)")
|
||||
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
|
||||
break;
|
||||
case 'supervisor':
|
||||
$pdo->prepare("INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)")
|
||||
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
|
||||
break;
|
||||
case 'coach':
|
||||
$pdo->prepare("INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)")
|
||||
->execute([':id' => $entityID, ':sid' => $supervisorID, ':u' => $username]);
|
||||
break;
|
||||
}
|
||||
$pdo->prepare(
|
||||
"INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
|
||||
VALUES (:uid, :u, :hash, :role, :eid, :mcp, :now)"
|
||||
)->execute([
|
||||
':uid' => $userID, ':u' => $username, ':hash' => $hash,
|
||||
':role' => $role, ':eid' => $entityID, ':mcp' => $mustChange, ':now' => $now,
|
||||
]);
|
||||
$pdo->commit();
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
// Fetch supervisor name for response
|
||||
$svUsername = null;
|
||||
if ($role === 'coach') {
|
||||
[$pdo2, $tmp2, $lk2] = qdb_open(false);
|
||||
$svr = $pdo2->prepare("SELECT username FROM supervisor WHERE supervisorID = :sid");
|
||||
$svr->execute([':sid' => $supervisorID]);
|
||||
$svUsername = $svr->fetchColumn() ?: null;
|
||||
qdb_discard($tmp2, $lk2);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"userID" => $userID,
|
||||
"entityID" => $entityID,
|
||||
"username" => $username,
|
||||
"role" => $role,
|
||||
"location" => $location,
|
||||
"supervisorID" => $role === 'coach' ? $supervisorID : null,
|
||||
"supervisorUsername" => $svUsername,
|
||||
"mustChangePassword" => $mustChange,
|
||||
"createdAt" => $now,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── DELETE: remove a user ─────────────────────────────────────────────────
|
||||
if ($method === 'DELETE') {
|
||||
$in = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$targetUserID = trim($in['userID'] ?? '');
|
||||
|
||||
if ($targetUserID === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "userID is required"]);
|
||||
exit;
|
||||
}
|
||||
if ($targetUserID === ($tokenRec['userID'] ?? '')) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Cannot delete your own account"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
|
||||
$row = $pdo->prepare("SELECT role, entityID FROM users WHERE userID = :uid");
|
||||
$row->execute([':uid' => $targetUserID]);
|
||||
$target = $row->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$target) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(404);
|
||||
echo json_encode(["error" => "User not found"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($callerRole === 'supervisor') {
|
||||
if ($target['role'] !== 'coach') {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Supervisors can only delete coaches"]);
|
||||
exit;
|
||||
}
|
||||
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid");
|
||||
$chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]);
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Coach is not under your supervision"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
$pdo->prepare("DELETE FROM users WHERE userID = :uid")->execute([':uid' => $targetUserID]);
|
||||
switch ($target['role']) {
|
||||
case 'admin':
|
||||
$pdo->prepare("DELETE FROM admin WHERE adminID = :id")->execute([':id' => $target['entityID']]);
|
||||
break;
|
||||
case 'supervisor':
|
||||
$pdo->prepare("DELETE FROM supervisor WHERE supervisorID = :id")->execute([':id' => $target['entityID']]);
|
||||
break;
|
||||
case 'coach':
|
||||
$pdo->prepare("DELETE FROM coach WHERE coachID = :id")->execute([':id' => $target['entityID']]);
|
||||
break;
|
||||
}
|
||||
$pdo->commit();
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
echo json_encode(["success" => true]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||
http_response_code(500);
|
||||
error_log($e->getMessage());
|
||||
echo json_encode(["error" => "Server error"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(405);
|
||||
echo json_encode(["error" => "Method not allowed"]);
|
||||
Reference in New Issue
Block a user