removed legacy files
This commit is contained in:
25
lib/encrypted_payload.php
Normal file
25
lib/encrypted_payload.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
/**
|
||||
* Application-layer encryption for sensitive mobile API payloads.
|
||||
* AES-256-CBC (IV prepended) + HKDF-SHA256 session key from the Bearer token (info: qdb-aes).
|
||||
*/
|
||||
|
||||
function qdb_sensitive_envelope(string $plainJson, string $tokenHex): array {
|
||||
$key = hkdf_session_key_from_token($tokenHex);
|
||||
return [
|
||||
'encrypted' => true,
|
||||
'payload' => base64_encode(aes256_cbc_encrypt_bytes($plainJson, $key)),
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_decrypt_sensitive_envelope(array $envelope, string $tokenHex): string {
|
||||
if (empty($envelope['encrypted']) || !isset($envelope['payload']) || !is_string($envelope['payload'])) {
|
||||
throw new InvalidArgumentException('Invalid encrypted envelope');
|
||||
}
|
||||
$raw = base64_decode($envelope['payload'], true);
|
||||
if ($raw === false) {
|
||||
throw new InvalidArgumentException('Invalid base64 payload');
|
||||
}
|
||||
$key = hkdf_session_key_from_token($tokenHex);
|
||||
return aes256_cbc_decrypt_bytes($raw, $key);
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
<?php
|
||||
|
||||
class ClientRepo
|
||||
{
|
||||
/**
|
||||
* List coaches visible to the caller.
|
||||
* Admin: all coaches. Supervisor: only their own coaches.
|
||||
*
|
||||
* @return list<array{coachID: string, username: string, supervisorID: string, supervisorUsername: ?string}>
|
||||
*/
|
||||
public static function listCoaches(PDO $pdo, string $callerRole, string $callerEntityID): array
|
||||
{
|
||||
if ($callerRole === 'admin') {
|
||||
return $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);
|
||||
}
|
||||
|
||||
$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]);
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* List clients visible to the caller (RBAC-filtered).
|
||||
*
|
||||
* @return list<array{clientCode: string, coachID: string, coachUsername: ?string}>
|
||||
*/
|
||||
public static function listClients(PDO $pdo, array $tokenRecord): array
|
||||
{
|
||||
[$clause, $params] = rbac_client_filter($tokenRecord);
|
||||
|
||||
$stmt = $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) {
|
||||
$stmt->bindValue($k, $v);
|
||||
}
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-assign clients to a coach, respecting RBAC.
|
||||
*
|
||||
* @param list<string> $clientCodes
|
||||
* @return int Number of rows actually updated.
|
||||
*/
|
||||
public static function assignToCoach(PDO $pdo, array $clientCodes, string $coachID, array $tokenRecord): int
|
||||
{
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRecord);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE client SET coachID = :cid WHERE clientCode = :cc AND ($rbacClause)"
|
||||
);
|
||||
$count = 0;
|
||||
|
||||
foreach ($clientCodes as $cc) {
|
||||
$cc = trim($cc);
|
||||
if ($cc === '') {
|
||||
continue;
|
||||
}
|
||||
$stmt->execute(array_merge([':cid' => $coachID, ':cc' => $cc], $rbacParams));
|
||||
$count += $stmt->rowCount();
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that a coach exists and is visible to the caller.
|
||||
*/
|
||||
public static function coachExists(PDO $pdo, string $coachID, string $callerRole, string $callerEntityID): bool
|
||||
{
|
||||
if ($callerRole === 'supervisor') {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid"
|
||||
);
|
||||
$stmt->execute([':cid' => $coachID, ':sid' => $callerEntityID]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
|
||||
$stmt->execute([':cid' => $coachID]);
|
||||
}
|
||||
|
||||
return (bool) $stmt->fetch();
|
||||
}
|
||||
}
|
||||
@ -1,146 +0,0 @@
|
||||
<?php
|
||||
|
||||
class QuestionRepo
|
||||
{
|
||||
public static function listByQuestionnaire(PDO $pdo, string $questionnaireID): array
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson
|
||||
FROM question WHERE questionnaireID = :qid ORDER BY orderIndex
|
||||
");
|
||||
$stmt->execute([':qid' => $questionnaireID]);
|
||||
$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'];
|
||||
}
|
||||
unset($opt);
|
||||
|
||||
$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);
|
||||
|
||||
return $questions;
|
||||
}
|
||||
|
||||
public static function findById(PDO $pdo, string $questionID): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM question WHERE questionID = :id');
|
||||
$stmt->execute([':id' => $questionID]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row === false ? null : $row;
|
||||
}
|
||||
|
||||
public static function create(
|
||||
PDO $pdo,
|
||||
string $id,
|
||||
string $questionnaireID,
|
||||
string $defaultText,
|
||||
string $type,
|
||||
int $orderIndex,
|
||||
int $isRequired,
|
||||
string $configJson
|
||||
): void {
|
||||
$pdo->prepare(
|
||||
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
|
||||
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)'
|
||||
)->execute([
|
||||
':id' => $id,
|
||||
':qid' => $questionnaireID,
|
||||
':t' => $defaultText,
|
||||
':ty' => $type,
|
||||
':o' => $orderIndex,
|
||||
':r' => $isRequired,
|
||||
':cj' => $configJson,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function update(
|
||||
PDO $pdo,
|
||||
string $id,
|
||||
string $defaultText,
|
||||
string $type,
|
||||
int $orderIndex,
|
||||
int $isRequired,
|
||||
string $configJson
|
||||
): void {
|
||||
$pdo->prepare(
|
||||
'UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj
|
||||
WHERE questionID = :id'
|
||||
)->execute([
|
||||
':t' => $defaultText,
|
||||
':ty' => $type,
|
||||
':o' => $orderIndex,
|
||||
':r' => $isRequired,
|
||||
':cj' => $configJson,
|
||||
':id' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function delete(PDO $pdo, string $id): void
|
||||
{
|
||||
$pdo->exec('PRAGMA foreign_keys = OFF;');
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$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();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
} finally {
|
||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
}
|
||||
}
|
||||
|
||||
public static function reorder(PDO $pdo, string $questionnaireID, array $orderedIds): void
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE question SET orderIndex = :o WHERE questionID = :id AND questionnaireID = :qid'
|
||||
);
|
||||
foreach ($orderedIds as $idx => $questionId) {
|
||||
$stmt->execute([
|
||||
':o' => (int) $idx,
|
||||
':id' => $questionId,
|
||||
':qid' => $questionnaireID,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function nextOrderIndex(PDO $pdo, string $questionnaireID): int
|
||||
{
|
||||
$max = $pdo->prepare(
|
||||
'SELECT COALESCE(MAX(orderIndex), 0) + 1 FROM question WHERE questionnaireID = :id'
|
||||
);
|
||||
$max->execute([':id' => $questionnaireID]);
|
||||
|
||||
return (int) $max->fetchColumn();
|
||||
}
|
||||
}
|
||||
@ -1,178 +0,0 @@
|
||||
<?php
|
||||
|
||||
class QuestionnaireRepo
|
||||
{
|
||||
/**
|
||||
* @return list<array{
|
||||
* questionnaireID: string,
|
||||
* name: string,
|
||||
* version: string,
|
||||
* state: string,
|
||||
* orderIndex: int,
|
||||
* showPoints: int,
|
||||
* conditionJson: string,
|
||||
* questionCount: int
|
||||
* }>
|
||||
*/
|
||||
public static function listAll(PDO $pdo): array
|
||||
{
|
||||
$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'] ?: '{}';
|
||||
$r['questionCount'] = (int) $r['questionCount'];
|
||||
}
|
||||
unset($r);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* id: string,
|
||||
* name: string,
|
||||
* showPoints: bool,
|
||||
* condition: array|stdClass
|
||||
* }>
|
||||
*/
|
||||
public static function listActive(PDO $pdo): array
|
||||
{
|
||||
$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(),
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function findById(PDO $pdo, string $id): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function create(
|
||||
PDO $pdo,
|
||||
string $id,
|
||||
string $name,
|
||||
string $version,
|
||||
string $state,
|
||||
int $orderIndex,
|
||||
int $showPoints,
|
||||
string $conditionJson
|
||||
): void {
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson)
|
||||
VALUES (:id, :n, :v, :s, :o, :sp, :cj)
|
||||
');
|
||||
$stmt->execute([
|
||||
':id' => $id,
|
||||
':n' => $name,
|
||||
':v' => $version,
|
||||
':s' => $state,
|
||||
':o' => $orderIndex,
|
||||
':sp' => $showPoints,
|
||||
':cj' => $conditionJson,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function update(
|
||||
PDO $pdo,
|
||||
string $id,
|
||||
string $name,
|
||||
string $version,
|
||||
string $state,
|
||||
int $orderIndex,
|
||||
int $showPoints,
|
||||
string $conditionJson
|
||||
): void {
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE questionnaire
|
||||
SET name = :n, version = :v, state = :s,
|
||||
orderIndex = :o, showPoints = :sp, conditionJson = :cj
|
||||
WHERE questionnaireID = :id
|
||||
');
|
||||
$stmt->execute([
|
||||
':n' => $name,
|
||||
':v' => $version,
|
||||
':s' => $state,
|
||||
':o' => $orderIndex,
|
||||
':sp' => $showPoints,
|
||||
':cj' => $conditionJson,
|
||||
':id' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function delete(PDO $pdo, string $id): void
|
||||
{
|
||||
$pdo->exec('PRAGMA foreign_keys = OFF;');
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$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) {
|
||||
$delAot = $pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id');
|
||||
$delAot->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();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
} finally {
|
||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
}
|
||||
}
|
||||
|
||||
public static function nextOrderIndex(PDO $pdo): int
|
||||
{
|
||||
return (int) $pdo->query('SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire')->fetchColumn();
|
||||
}
|
||||
}
|
||||
@ -1,124 +0,0 @@
|
||||
<?php
|
||||
|
||||
class ResultRepo
|
||||
{
|
||||
/**
|
||||
* Build the full results payload for a questionnaire, optionally filtered to one client.
|
||||
* Uses rbac_client_filter() so callers only see what their role permits.
|
||||
*
|
||||
* @return array{questionnaire: array, questions: list<array>, clients: list<array>}
|
||||
*/
|
||||
public static function getQuestionnaireResults(
|
||||
PDO $pdo,
|
||||
string $questionnaireID,
|
||||
array $tokenRecord,
|
||||
?string $clientCode = null
|
||||
): array {
|
||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
||||
$qn->execute([':id' => $questionnaireID]);
|
||||
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$questionnaire) {
|
||||
return ['questionnaire' => null, 'questions' => [], 'clients' => []];
|
||||
}
|
||||
|
||||
$qStmt = $pdo->prepare("
|
||||
SELECT questionID, defaultText, type, orderIndex, isRequired
|
||||
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
|
||||
");
|
||||
$qStmt->execute([':id' => $questionnaireID]);
|
||||
$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($opt);
|
||||
}
|
||||
unset($q);
|
||||
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRecord, 'cl');
|
||||
|
||||
$sql = "
|
||||
SELECT cl.clientCode, cl.coachID,
|
||||
co.username AS coachUsername,
|
||||
sv.username AS supervisorUsername,
|
||||
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
|
||||
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
|
||||
WHERE $rbacClause
|
||||
";
|
||||
$params = array_merge([':qnid' => $questionnaireID], $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);
|
||||
|
||||
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) {
|
||||
self::castClientInts($c);
|
||||
|
||||
$answerStmt->execute(array_merge([$c['clientCode']], $questionIDs));
|
||||
$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) {
|
||||
self::castClientInts($c);
|
||||
$c['answers'] = (object) [];
|
||||
}
|
||||
unset($c);
|
||||
}
|
||||
|
||||
return [
|
||||
'questionnaire' => $questionnaire,
|
||||
'questions' => $questions,
|
||||
'clients' => $clients,
|
||||
];
|
||||
}
|
||||
|
||||
private static function castClientInts(array &$c): void
|
||||
{
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
class SessionRepo
|
||||
{
|
||||
public static function create(
|
||||
PDO $pdo,
|
||||
string $token,
|
||||
string $userID,
|
||||
string $role,
|
||||
string $entityID,
|
||||
int $ttl,
|
||||
bool $temp = false
|
||||
): void {
|
||||
$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' => $userID,
|
||||
':role' => $role,
|
||||
':eid' => $entityID,
|
||||
':ca' => $now,
|
||||
':ea' => $now + $ttl,
|
||||
':tmp' => (int) $temp,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null Null when token is missing or expired.
|
||||
*/
|
||||
public static function findByToken(PDO $pdo, string $token): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT * FROM session WHERE token = :t AND expiresAt >= :now"
|
||||
);
|
||||
$stmt->execute([':t' => $token, ':now' => time()]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function revoke(PDO $pdo, string $token): void
|
||||
{
|
||||
$pdo->prepare("DELETE FROM session WHERE token = :t")
|
||||
->execute([':t' => $token]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all expired sessions.
|
||||
* @return int Number of rows deleted.
|
||||
*/
|
||||
public static function cleanup(PDO $pdo): int
|
||||
{
|
||||
$stmt = $pdo->prepare("DELETE FROM session WHERE expiresAt < :now");
|
||||
$stmt->execute([':now' => time()]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
class TranslationRepo
|
||||
{
|
||||
private const TABLE_MAP = [
|
||||
'question' => ['table' => 'question_translation', 'pk' => 'questionID'],
|
||||
'answer_option' => ['table' => 'answer_option_translation', 'pk' => 'answerOptionID'],
|
||||
'string' => ['table' => 'string_translation', 'pk' => 'stringKey'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @return list<array{languageCode: string, name: string}>
|
||||
*/
|
||||
public static function listLanguages(PDO $pdo): array
|
||||
{
|
||||
return $pdo->query(
|
||||
"SELECT languageCode, name FROM language ORDER BY languageCode"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public static function upsertLanguage(PDO $pdo, string $code, string $name): void
|
||||
{
|
||||
$pdo->prepare("
|
||||
INSERT INTO language (languageCode, name) VALUES (:lc, :n)
|
||||
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name
|
||||
")->execute([':lc' => $code, ':n' => $name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a language and all its translations across all three tables.
|
||||
*/
|
||||
public static function deleteLanguage(PDO $pdo, string $code): void
|
||||
{
|
||||
$pdo->prepare("DELETE FROM language WHERE languageCode = :lc")
|
||||
->execute([':lc' => $code]);
|
||||
$pdo->prepare("DELETE FROM question_translation WHERE languageCode = :lc")
|
||||
->execute([':lc' => $code]);
|
||||
$pdo->prepare("DELETE FROM answer_option_translation WHERE languageCode = :lc")
|
||||
->execute([':lc' => $code]);
|
||||
$pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")
|
||||
->execute([':lc' => $code]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all translations for a given entity.
|
||||
*
|
||||
* @param string $type question|answer_option|string
|
||||
* @return list<array{languageCode: string, text: string}>
|
||||
*/
|
||||
public static function getForEntity(PDO $pdo, string $type, string $id): array
|
||||
{
|
||||
$meta = self::meta($type);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT languageCode, text FROM {$meta['table']} WHERE {$meta['pk']} = :id"
|
||||
);
|
||||
$stmt->execute([':id' => $id]);
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a single translation.
|
||||
*
|
||||
* @param string $type question|answer_option|string
|
||||
*/
|
||||
public static function upsert(PDO $pdo, string $type, string $id, string $lang, string $text): void
|
||||
{
|
||||
$meta = self::meta($type);
|
||||
|
||||
$pdo->prepare("
|
||||
INSERT INTO {$meta['table']} ({$meta['pk']}, languageCode, text)
|
||||
VALUES (:id, :lang, :t)
|
||||
ON CONFLICT({$meta['pk']}, languageCode) DO UPDATE SET text = excluded.text
|
||||
")->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single translation.
|
||||
*
|
||||
* @param string $type question|answer_option|string
|
||||
*/
|
||||
public static function delete(PDO $pdo, string $type, string $id, string $lang): void
|
||||
{
|
||||
$meta = self::meta($type);
|
||||
|
||||
$pdo->prepare(
|
||||
"DELETE FROM {$meta['table']} WHERE {$meta['pk']} = :id AND languageCode = :lang"
|
||||
)->execute([':id' => $id, ':lang' => $lang]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{table: string, pk: string}
|
||||
*/
|
||||
private static function meta(string $type): array
|
||||
{
|
||||
if (!isset(self::TABLE_MAP[$type])) {
|
||||
throw new InvalidArgumentException("Unknown translation type: $type");
|
||||
}
|
||||
|
||||
return self::TABLE_MAP[$type];
|
||||
}
|
||||
}
|
||||
@ -1,211 +0,0 @@
|
||||
<?php
|
||||
|
||||
class UserRepo
|
||||
{
|
||||
/**
|
||||
* Full user list with role-table JOINs for location, supervisorID, supervisorUsername.
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public static function listAll(PDO $pdo): array
|
||||
{
|
||||
return $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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coaches that belong to the given supervisor.
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public static function listBySupervisor(PDO $pdo, string $supervisorID): array
|
||||
{
|
||||
$svName = self::getSupervisorName($pdo, $supervisorID);
|
||||
|
||||
$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' => $supervisorID, ':svname' => $svName]);
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{supervisorID: string, username: string, location: string}>
|
||||
*/
|
||||
public static function listSupervisors(PDO $pdo): array
|
||||
{
|
||||
return $pdo->query(
|
||||
"SELECT supervisorID, username, location FROM supervisor ORDER BY username"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function findByUsername(PDO $pdo, string $username): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :u");
|
||||
$stmt->execute([':u' => $username]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{role: string, entityID: string}|null
|
||||
*/
|
||||
public static function findById(PDO $pdo, string $userID): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT role, entityID FROM users WHERE userID = :uid");
|
||||
$stmt->execute([':uid' => $userID]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function usernameExists(PDO $pdo, string $username): bool
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT 1 FROM users WHERE username = :u");
|
||||
$stmt->execute([':u' => $username]);
|
||||
|
||||
return (bool) $stmt->fetch();
|
||||
}
|
||||
|
||||
public static function supervisorExists(PDO $pdo, string $supervisorID): bool
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT 1 FROM supervisor WHERE supervisorID = :sid");
|
||||
$stmt->execute([':sid' => $supervisorID]);
|
||||
|
||||
return (bool) $stmt->fetch();
|
||||
}
|
||||
|
||||
public static function coachBelongsToSupervisor(PDO $pdo, string $coachEntityID, string $supervisorID): bool
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid"
|
||||
);
|
||||
$stmt->execute([':cid' => $coachEntityID, ':svid' => $supervisorID]);
|
||||
|
||||
return (bool) $stmt->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new user inside a transaction: role-specific table first, then users.
|
||||
* Caller is responsible for wrapping in qdb_open(true) / qdb_save().
|
||||
*/
|
||||
public static function create(
|
||||
PDO $pdo,
|
||||
string $userID,
|
||||
string $entityID,
|
||||
string $username,
|
||||
string $passwordHash,
|
||||
string $role,
|
||||
string $location,
|
||||
string $supervisorID,
|
||||
int $mustChangePassword
|
||||
): void {
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
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' => $passwordHash,
|
||||
':role' => $role,
|
||||
':eid' => $entityID,
|
||||
':mcp' => $mustChangePassword,
|
||||
':now' => time(),
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user + role-specific row in a transaction.
|
||||
*/
|
||||
public static function delete(PDO $pdo, string $userID, string $role, string $entityID): void
|
||||
{
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare("DELETE FROM users WHERE userID = :uid")
|
||||
->execute([':uid' => $userID]);
|
||||
|
||||
switch ($role) {
|
||||
case 'admin':
|
||||
$pdo->prepare("DELETE FROM admin WHERE adminID = :id")
|
||||
->execute([':id' => $entityID]);
|
||||
break;
|
||||
case 'supervisor':
|
||||
$pdo->prepare("DELETE FROM supervisor WHERE supervisorID = :id")
|
||||
->execute([':id' => $entityID]);
|
||||
break;
|
||||
case 'coach':
|
||||
$pdo->prepare("DELETE FROM coach WHERE coachID = :id")
|
||||
->execute([':id' => $entityID]);
|
||||
break;
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public static function updatePassword(PDO $pdo, string $userID, string $newHash): void
|
||||
{
|
||||
$pdo->prepare(
|
||||
"UPDATE users SET passwordHash = :h, mustChangePassword = 0 WHERE userID = :uid"
|
||||
)->execute([':h' => $newHash, ':uid' => $userID]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Username or empty string if not found.
|
||||
*/
|
||||
public static function getSupervisorName(PDO $pdo, string $supervisorID): string
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT username FROM supervisor WHERE supervisorID = :svid");
|
||||
$stmt->execute([':svid' => $supervisorID]);
|
||||
|
||||
return $stmt->fetchColumn() ?: '';
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/encrypted_payload.php';
|
||||
|
||||
function json_success(mixed $data, int $status = 200): never {
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
@ -22,3 +24,26 @@ function read_json_body(): array {
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function read_encrypted_json_body(string $tokenHex): array {
|
||||
$outer = read_json_body();
|
||||
if (empty($outer['encrypted'])) {
|
||||
json_error('ENCRYPTION_REQUIRED', 'Sensitive requests must send an encrypted payload', 400);
|
||||
}
|
||||
try {
|
||||
$plain = qdb_decrypt_sensitive_envelope($outer, $tokenHex);
|
||||
} catch (Throwable $e) {
|
||||
error_log('decrypt body failed: ' . $e->getMessage());
|
||||
json_error('DECRYPT_FAILED', 'Could not decrypt request payload', 400);
|
||||
}
|
||||
$data = json_decode($plain, true);
|
||||
if (!is_array($data)) {
|
||||
json_error('INVALID_BODY', 'Decrypted body must be valid JSON object', 400);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function json_success_sensitive(mixed $data, string $tokenHex, int $status = 200): never {
|
||||
$plain = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
json_success(qdb_sensitive_envelope($plain, $tokenHex), $status);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user