diff --git a/.gitignore b/.gitignore index 08b9cc8..2ca2fb4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ tokens.jsonl valid_tokens.txt .env +*.sh # Encrypted database, temp files, backups uploads/* diff --git a/api/index.php b/api/index.php index c77d206..37852ee 100644 --- a/api/index.php +++ b/api/index.php @@ -38,6 +38,7 @@ $routes = [ 'translations' => __DIR__ . '/../handlers/translations.php', 'users' => __DIR__ . '/../handlers/users.php', 'assignments' => __DIR__ . '/../handlers/assignments.php', + 'clients' => __DIR__ . '/../handlers/clients.php', 'results' => __DIR__ . '/../handlers/results.php', 'export' => __DIR__ . '/../handlers/export.php', 'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php', diff --git a/handlers/assignments.php b/handlers/assignments.php index 8a1985b..40149fa 100644 --- a/handlers/assignments.php +++ b/handlers/assignments.php @@ -30,7 +30,7 @@ case 'GET': $coaches = $stmt->fetchAll(PDO::FETCH_ASSOC); } - [$clause, $params] = rbac_client_filter($tokenRec); + [$clause, $params] = rbac_client_filter($tokenRec, 'cl'); $clientStmt = $pdo->prepare( "SELECT cl.clientCode, cl.coachID, co.username AS coachUsername FROM client cl diff --git a/handlers/clients.php b/handlers/clients.php new file mode 100644 index 0000000..6d19187 --- /dev/null +++ b/handlers/clients.php @@ -0,0 +1,123 @@ +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.clientCode" + ); + foreach ($params as $k => $v) $stmt->bindValue($k, $v); + $stmt->execute(); + $clients = $stmt->fetchAll(PDO::FETCH_ASSOC); + + qdb_discard($tmpDb, $lockFp); + json_success(['clients' => $clients]); + } catch (Throwable $e) { + if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp); + error_log($e->getMessage()); + json_error('SERVER_ERROR', 'Server error', 500); + } + break; + +case 'POST': + $body = read_json_body(); + $clientCode = trim($body['clientCode'] ?? ''); + $coachID = trim($body['coachID'] ?? ''); + + if ($clientCode === '' || $coachID === '') { + json_error('MISSING_FIELDS', 'clientCode and coachID are required', 400); + } + + try { + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + + // Validate coach exists and caller is allowed to assign to them + if ($callerRole === 'supervisor') { + $chkCoach = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid"); + $chkCoach->execute([':cid' => $coachID, ':sid' => $callerEntityID]); + } else { + $chkCoach = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid"); + $chkCoach->execute([':cid' => $coachID]); + } + if (!$chkCoach->fetch()) { + qdb_discard($tmpDb, $lockFp); + json_error('NOT_FOUND', 'Coach not found or not authorized', 400); + } + + $chk = $pdo->prepare("SELECT clientCode FROM client WHERE clientCode = :cc"); + $chk->execute([':cc' => $clientCode]); + if ($chk->fetch()) { + qdb_discard($tmpDb, $lockFp); + json_error('DUPLICATE', 'Client code already exists', 409); + } + + $pdo->prepare("INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)") + ->execute([':cc' => $clientCode, ':cid' => $coachID]); + + qdb_save($tmpDb, $lockFp); + json_success(['clientCode' => $clientCode, 'coachID' => $coachID]); + } catch (Throwable $e) { + if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp); + error_log($e->getMessage()); + json_error('SERVER_ERROR', 'Server error', 500); + } + break; + +case 'DELETE': + $body = read_json_body(); + $clientCode = trim($body['clientCode'] ?? ''); + + if ($clientCode === '') { + json_error('MISSING_FIELDS', 'clientCode is required', 400); + } + + try { + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + + // Verify the client exists and the caller can see it (RBAC) + [$clause, $params] = rbac_client_filter($tokenRec, 'cl'); + $chk = $pdo->prepare( + "SELECT clientCode FROM client cl WHERE cl.clientCode = :cc AND ($clause)" + ); + $chk->execute(array_merge([':cc' => $clientCode], $params)); + if (!$chk->fetch()) { + qdb_discard($tmpDb, $lockFp); + json_error('NOT_FOUND', 'Client not found or not authorized', 404); + } + + $pdo->beginTransaction(); + $pdo->prepare("DELETE FROM client_answer WHERE clientCode = :cc") + ->execute([':cc' => $clientCode]); + $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode = :cc") + ->execute([':cc' => $clientCode]); + $pdo->prepare("DELETE FROM client WHERE clientCode = :cc") + ->execute([':cc' => $clientCode]); + $pdo->commit(); + + qdb_save($tmpDb, $lockFp); + json_success([]); + } catch (Throwable $e) { + if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack(); + if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp); + error_log($e->getMessage()); + json_error('SERVER_ERROR', 'Server error', 500); + } + break; + +default: + json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); +} diff --git a/website/index.html b/website/index.html index 03a6837..1ce969a 100644 --- a/website/index.html +++ b/website/index.html @@ -27,6 +27,12 @@ Users +
  • + + + Clients + +
  • diff --git a/website/js/app.js b/website/js/app.js index ddc2bcd..107dc92 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -6,6 +6,7 @@ import { resultsPage } from './pages/results.js'; import { exportPage } from './pages/export.js'; import { usersPage } from './pages/users.js'; import { assignmentsPage } from './pages/assignments.js'; +import { clientsPage } from './pages/clients.js'; // Auth state export function isLoggedIn() { @@ -86,6 +87,7 @@ addRoute('/questionnaire/:id', authGuard(editorPage)); addRoute('/export', authGuard(exportPage)); addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage)); addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage)); +addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage)); // Nav link handling & logout document.addEventListener('DOMContentLoaded', () => { diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js new file mode 100644 index 0000000..628b6d1 --- /dev/null +++ b/website/js/pages/clients.js @@ -0,0 +1,206 @@ +import { apiGet, apiPost, apiDelete } from '../api.js'; +import { showToast } from '../app.js'; + +let clientsList = []; +let coachesList = []; + +export async function clientsPage() { + const app = document.getElementById('app'); + + app.innerHTML = ` + + +
    + `; + + document.getElementById('showCreateFormBtn').addEventListener('click', () => { + const wrapper = document.getElementById('createClientWrapper'); + if (wrapper.style.display === 'none') { + showCreateForm(); + } else { + hideCreateForm(); + } + }); + + await loadClients(); +} + +async function loadClients() { + try { + const [clientsData, assignData] = await Promise.all([ + apiGet('clients.php'), + apiGet('assignments.php'), + ]); + clientsList = clientsData.clients || []; + coachesList = assignData.coaches || []; + renderClients(); + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('clientsContent').innerHTML = + `

    ${esc(e.message)}

    `; + } +} + +function renderClients() { + const container = document.getElementById('clientsContent'); + + if (!clientsList.length) { + container.innerHTML = ` +
    +
    +

    No clients found

    +

    Create the first client above.

    +
    +
    `; + return; + } + + container.innerHTML = ` +
    +
    + + + + + + + + + + ${clientsList.map(c => clientRowHTML(c)).join('')} + +
    Client CodeCurrent CoachActions
    +
    +
    `; + + container.querySelectorAll('.delete-client-btn').forEach(btn => { + btn.addEventListener('click', () => deleteClient(btn.dataset.code)); + }); +} + +function clientRowHTML(c) { + const coach = c.coachUsername + ? `${esc(c.coachUsername)}` + : `Unassigned`; + + return ` + + ${esc(c.clientCode)} + ${coach} + + + + `; +} + +async function deleteClient(clientCode) { + if (!confirm(`Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`)) return; + try { + await apiDelete('clients.php', { clientCode }); + clientsList = clientsList.filter(c => c.clientCode !== clientCode); + showToast(`Client "${clientCode}" deleted`, 'success'); + renderClients(); + } catch (e) { + showToast(e.message, 'error'); + } +} + +function showCreateForm() { + if (!coachesList.length) { + showToast('No coaches available. Create a coach first.', 'error'); + return; + } + + const wrapper = document.getElementById('createClientWrapper'); + wrapper.style.display = ''; + wrapper.innerHTML = ` +
    +

    Create New Client

    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + +
    + `; + + const input = document.getElementById('cc_code'); + input.focus(); + wrapper.querySelectorAll('input').forEach(inp => { + inp.addEventListener('keydown', (e) => { + if (e.key === 'Enter') submitCreateClient(); + if (e.key === 'Escape') hideCreateForm(); + }); + }); + + document.getElementById('cc_submit').addEventListener('click', submitCreateClient); + document.getElementById('cc_cancel').addEventListener('click', hideCreateForm); +} + +function hideCreateForm() { + const wrapper = document.getElementById('createClientWrapper'); + if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; } +} + +async function submitCreateClient() { + const errEl = document.getElementById('cc_error'); + errEl.style.display = 'none'; + + const clientCode = document.getElementById('cc_code').value.trim(); + const coachID = document.getElementById('cc_coach').value; + + if (!clientCode) { showError(errEl, 'Client code is required'); return; } + if (!coachID) { showError(errEl, 'Please select a coach'); return; } + + const btn = document.getElementById('cc_submit'); + btn.disabled = true; + btn.textContent = 'Creating...'; + + try { + const data = await apiPost('clients.php', { clientCode, coachID }); + const coach = coachesList.find(c => c.coachID === coachID); + clientsList.push({ + clientCode: data.clientCode, + coachID: coachID, + coachUsername: coach?.username ?? null, + }); + showToast(`Client "${data.clientCode}" created`, 'success'); + hideCreateForm(); + renderClients(); + } catch (e) { + showError(errEl, e.message); + btn.disabled = false; + btn.textContent = 'Create Client'; + } +} + +function showError(el, msg) { + el.textContent = msg; + el.style.display = ''; +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +}