From 7fefc264f488749934cb6b827acaa9d86c25ff8d Mon Sep 17 00:00:00 2001 From: "tom.hempel" Date: Mon, 29 Jun 2026 13:26:08 +0200 Subject: [PATCH] session cylces and adjustments --- api/index.php | 1 + db_init.php | 42 ++- handlers/app_questionnaires.php | 21 +- handlers/program_sessions.php | 172 ++++++++++ lib/program_sessions.php | 377 ++++++++++++++++++++++ schema.sql | 31 ++ tests/Integration/ProgramSessionsTest.php | 194 +++++++++++ tests/Unit/ProgramSessionsTest.php | 55 ++++ website/index.html | 6 + website/js/app.js | 5 + website/js/pages/home.js | 1 + website/js/pages/sessions.js | 354 ++++++++++++++++++++ 12 files changed, 1256 insertions(+), 3 deletions(-) create mode 100644 handlers/program_sessions.php create mode 100644 lib/program_sessions.php create mode 100644 tests/Integration/ProgramSessionsTest.php create mode 100644 tests/Unit/ProgramSessionsTest.php create mode 100644 website/js/pages/sessions.js diff --git a/api/index.php b/api/index.php index 9ebee27..98d409e 100644 --- a/api/index.php +++ b/api/index.php @@ -50,6 +50,7 @@ $routes = [ 'export' => __DIR__ . '/../handlers/export.php', 'analytics' => __DIR__ . '/../handlers/analytics.php', 'scoring_profiles' => __DIR__ . '/../handlers/scoring_profiles.php', + 'program_sessions' => __DIR__ . '/../handlers/program_sessions.php', 'coaches' => __DIR__ . '/../handlers/coaches.php', 'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php', 'logout' => __DIR__ . '/../handlers/logout.php', diff --git a/db_init.php b/db_init.php index 9b0d95e..cb9e477 100644 --- a/db_init.php +++ b/db_init.php @@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) { define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); } define('QDB_SCHEMA', __DIR__ . '/schema.sql'); -define('QDB_VERSION', 12); +define('QDB_VERSION', 14); function qdb_table_exists(PDO $pdo, string $table): bool { $stmt = $pdo->prepare( @@ -285,6 +285,46 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { } } + if (!qdb_table_exists($pdo, 'program_session')) { + $pdo->exec(" + CREATE TABLE program_session ( + sessionID TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + sessionNumber INTEGER NOT NULL DEFAULT 1, + orderIndex INTEGER NOT NULL DEFAULT 0, + isActive INTEGER NOT NULL DEFAULT 1, + createdAt INTEGER NOT NULL DEFAULT 0, + updatedAt INTEGER NOT NULL DEFAULT 0 + ) + "); + $pdo->exec(" + CREATE TABLE program_session_questionnaire ( + sessionID TEXT NOT NULL, + questionnaireID TEXT NOT NULL, + orderIndex INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (sessionID, questionnaireID), + FOREIGN KEY(sessionID) REFERENCES program_session(sessionID) ON DELETE CASCADE, + FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE + ) + "); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_program_session_order ON program_session(orderIndex, sessionNumber)'); + $changed = true; + } + + if (!qdb_table_exists($pdo, 'client_program_cycle')) { + $pdo->exec(" + CREATE TABLE client_program_cycle ( + clientCode TEXT NOT NULL PRIMARY KEY, + cycleNumber INTEGER NOT NULL DEFAULT 1, + cycleStartedAt INTEGER NOT NULL DEFAULT 0, + updatedAt INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE + ) + "); + $changed = true; + } + if (qdb_table_exists($pdo, 'questionnaire_submission')) { require_once __DIR__ . '/lib/submissions.php'; if (qdb_backfill_submissions_from_live($pdo)) { diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index afa655f..3847af6 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -8,6 +8,7 @@ * GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted) * GET ?answersBulk=1 -> all assigned clients' answers in one payload (coach, encrypted) * GET ?scoringProfiles=1 -> active scoring profile definitions (encrypted) + * GET ?sessions=1 -> active program session definitions with questionnaire assignments * GET ?scoringReview=1 -> coach review state (coachBand only; computed on device) * POST action=coachScoringReview -> coach agrees with or overrides calculated band (encrypted) * POST -> submit interview answers for a client (coach / supervisor / admin). @@ -330,6 +331,10 @@ if ($method === 'POST') { qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID); + require_once __DIR__ . '/../lib/program_sessions.php'; + $cycleAdvanced = qdb_maybe_advance_program_cycle($pdo, $clientCode); + $cycle = qdb_client_program_cycle_get($pdo, $clientCode); + $pdo->commit(); qdb_save($tmpDb, $lockFp); @@ -338,6 +343,8 @@ if ($method === 'POST') { 'sumPoints' => $sumPoints, 'structureRevision' => $submitRev, 'legacySubmit' => $isLegacySubmit, + 'cycleAdvanced' => $cycleAdvanced, + 'cycleNumber' => $cycle['cycleNumber'], ]); } catch (Throwable $e) { qdb_handler_fail($e, 'Submit questionnaire', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); @@ -357,9 +364,17 @@ $fetchClients = $_GET['clients'] ?? ''; $fetchAnswers = $_GET['answers'] ?? ''; $fetchAnswersBulk = $_GET['answersBulk'] ?? ''; $fetchScoringProfiles = $_GET['scoringProfiles'] ?? ''; +$fetchProgramSessions = $_GET['sessions'] ?? ''; $fetchScoringReview = $_GET['scoringReview'] ?? ''; $clientCode = trim($_GET['clientCode'] ?? ''); +if ($fetchProgramSessions) { + require_once __DIR__ . '/../lib/program_sessions.php'; + json_success(qdb_app_program_sessions_payload($pdo)); + qdb_discard($tmpDb, $lockFp); + return; +} + if ($fetchScoringProfiles) { require_role(['coach'], $tokenRec); require_once __DIR__ . '/../lib/scoring.php'; @@ -468,10 +483,12 @@ if ($fetchClients) { } } - $clients = array_map(function ($code) use ($completions) { + require_once __DIR__ . '/../lib/program_sessions.php'; + $clients = array_map(function ($code) use ($completions, $pdo) { return [ - 'clientCode' => $code, + 'clientCode' => $code, 'completedQuestionnaires' => $completions[$code] ?? [], + 'programProgress' => qdb_client_program_progress($pdo, $code), ]; }, $clientCodes); diff --git a/handlers/program_sessions.php b/handlers/program_sessions.php new file mode 100644 index 0000000..d6e7c6a --- /dev/null +++ b/handlers/program_sessions.php @@ -0,0 +1,172 @@ + $session, + 'settings' => qdb_program_session_settings_get($pdo), + ]); + } + json_success([ + 'sessions' => qdb_list_program_sessions($pdo), + 'settings' => qdb_program_session_settings_get($pdo), + ]); + qdb_discard($tmpDb, $lockFp); + } catch (Throwable $e) { + qdb_handler_fail($e, 'List program sessions', null, $tmpDb ?? null, $lockFp ?? null); + } + break; + +case 'POST': + require_role(['admin', 'supervisor'], $tokenRec); + $body = read_json_body(); + $name = trim($body['name'] ?? ''); + if ($name === '') { + json_error('BAD_REQUEST', 'name is required', 400); + } + $sessionNumber = max(1, (int)($body['sessionNumber'] ?? 1)); + $orderIndex = max(0, (int)($body['orderIndex'] ?? 0)); + $members = qdb_parse_program_session_questionnaires($body['questionnaires'] ?? []); + try { + [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); + $err = qdb_validate_program_session_questionnaires($pdo, $members); + if ($err !== null) { + qdb_discard($tmpDb, $lockFp); + json_error('INVALID_FIELD', $err, 400); + } + $sessionID = bin2hex(random_bytes(16)); + $now = time(); + $pdo->prepare( + 'INSERT INTO program_session + (sessionID, name, description, sessionNumber, orderIndex, isActive, createdAt, updatedAt) + VALUES (:id, :name, :desc, :num, :ord, :act, :ca, :ua)' + )->execute([ + ':id' => $sessionID, + ':name' => $name, + ':desc' => trim($body['description'] ?? ''), + ':num' => $sessionNumber, + ':ord' => $orderIndex, + ':act' => !empty($body['isActive']) || !array_key_exists('isActive', $body) ? 1 : 0, + ':ca' => $now, + ':ua' => $now, + ]); + qdb_sync_program_session_questionnaires($pdo, $sessionID, $members); + qdb_save($tmpDb, $lockFp); + json_success(['session' => qdb_get_program_session($pdo, $sessionID)]); + } catch (Throwable $e) { + qdb_handler_fail($e, 'Create program session', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); + } + break; + +case 'PUT': + require_role(['admin', 'supervisor'], $tokenRec); + $body = read_json_body(); + $sessionID = trim($body['sessionID'] ?? ''); + if ($sessionID === '') { + json_error('BAD_REQUEST', 'sessionID is required', 400); + } + try { + [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); + $existing = qdb_get_program_session($pdo, $sessionID); + if (!$existing) { + qdb_discard($tmpDb, $lockFp); + json_error('NOT_FOUND', 'Session not found', 404); + } + $name = trim($body['name'] ?? $existing['name']); + if ($name === '') { + qdb_discard($tmpDb, $lockFp); + json_error('BAD_REQUEST', 'name is required', 400); + } + $members = array_key_exists('questionnaires', $body) + ? qdb_parse_program_session_questionnaires($body['questionnaires']) + : null; + if ($members !== null) { + $err = qdb_validate_program_session_questionnaires($pdo, $members); + if ($err !== null) { + qdb_discard($tmpDb, $lockFp); + json_error('INVALID_FIELD', $err, 400); + } + } + $pdo->prepare( + 'UPDATE program_session SET + name = :name, + description = :desc, + sessionNumber = :num, + orderIndex = :ord, + isActive = :act, + updatedAt = :ua + WHERE sessionID = :id' + )->execute([ + ':name' => $name, + ':desc' => trim($body['description'] ?? $existing['description']), + ':num' => isset($body['sessionNumber']) + ? max(1, (int)$body['sessionNumber']) + : $existing['sessionNumber'], + ':ord' => isset($body['orderIndex']) + ? max(0, (int)$body['orderIndex']) + : $existing['orderIndex'], + ':act' => isset($body['isActive']) + ? (!empty($body['isActive']) ? 1 : 0) + : $existing['isActive'], + ':ua' => time(), + ':id' => $sessionID, + ]); + if ($members !== null) { + qdb_sync_program_session_questionnaires($pdo, $sessionID, $members); + } + qdb_save($tmpDb, $lockFp); + json_success(['session' => qdb_get_program_session($pdo, $sessionID)]); + } catch (Throwable $e) { + qdb_handler_fail($e, 'Update program session', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); + } + break; + +case 'PATCH': + require_role(['admin', 'supervisor'], $tokenRec); + $body = read_json_body(); + try { + [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); + $settings = qdb_program_session_settings_save($pdo, $body); + qdb_save($tmpDb, $lockFp); + json_success(['settings' => $settings]); + } catch (Throwable $e) { + qdb_handler_fail($e, 'Update program session settings', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); + } + break; + +case 'DELETE': + require_role(['admin', 'supervisor'], $tokenRec); + $body = read_json_body(); + $sessionID = trim($body['sessionID'] ?? ''); + if ($sessionID === '') { + json_error('BAD_REQUEST', 'sessionID is required', 400); + } + try { + [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); + $pdo->prepare('DELETE FROM program_session WHERE sessionID = :id')->execute([':id' => $sessionID]); + qdb_save($tmpDb, $lockFp); + json_success(['deleted' => true]); + } catch (Throwable $e) { + qdb_handler_fail($e, 'Delete program session', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); + } + break; + +default: + json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); +} diff --git a/lib/program_sessions.php b/lib/program_sessions.php new file mode 100644 index 0000000..b5a917d --- /dev/null +++ b/lib/program_sessions.php @@ -0,0 +1,377 @@ +prepare( + 'SELECT settingValue FROM system_setting WHERE settingKey = :k' + ); + $stmt->execute([':k' => QDB_PROGRAM_SESSIONS_AUTO_REPEAT_KEY]); + $val = $stmt->fetchColumn(); + return [ + 'autoRepeatSessions' => $val !== false && (int)$val === 1, + ]; +} + +/** + * @param array{autoRepeatSessions?: bool} $partial + * @return array{autoRepeatSessions: bool} + */ +function qdb_program_session_settings_save(PDO $pdo, array $partial): array +{ + $current = qdb_program_session_settings_get($pdo); + if (array_key_exists('autoRepeatSessions', $partial)) { + $current['autoRepeatSessions'] = !empty($partial['autoRepeatSessions']); + } + require_once __DIR__ . '/settings.php'; + qdb_ensure_system_setting_table($pdo); + $stmt = $pdo->prepare( + 'INSERT INTO system_setting (settingKey, settingValue) + VALUES (:k, :v) + ON CONFLICT(settingKey) DO UPDATE SET settingValue = excluded.settingValue' + ); + $stmt->execute([ + ':k' => QDB_PROGRAM_SESSIONS_AUTO_REPEAT_KEY, + ':v' => $current['autoRepeatSessions'] ? '1' : '0', + ]); + return $current; +} + +/** + * @return list> + */ +function qdb_program_session_questionnaires(PDO $pdo, string $sessionID): array { + $stmt = $pdo->prepare( + 'SELECT psq.questionnaireID, psq.orderIndex, q.name + FROM program_session_questionnaire psq + LEFT JOIN questionnaire q ON q.questionnaireID = psq.questionnaireID + WHERE psq.sessionID = :sid + ORDER BY psq.orderIndex, psq.questionnaireID' + ); + $stmt->execute([':sid' => $sessionID]); + $rows = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $rows[] = [ + 'questionnaireID' => (string)$row['questionnaireID'], + 'name' => (string)($row['name'] ?? ''), + 'orderIndex' => (int)$row['orderIndex'], + ]; + } + return $rows; +} + +/** + * @return list> + */ +function qdb_list_program_sessions(PDO $pdo, bool $activeOnly = false): array { + $sql = 'SELECT sessionID, name, description, sessionNumber, orderIndex, isActive, createdAt, updatedAt + FROM program_session'; + if ($activeOnly) { + $sql .= ' WHERE isActive = 1'; + } + $sql .= ' ORDER BY orderIndex, sessionNumber, name'; + $stmt = $pdo->query($sql); + $sessions = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $row['isActive'] = (int)$row['isActive']; + $row['sessionNumber'] = (int)$row['sessionNumber']; + $row['orderIndex'] = (int)$row['orderIndex']; + $row['questionnaires'] = qdb_program_session_questionnaires($pdo, $row['sessionID']); + $sessions[] = $row; + } + return $sessions; +} + +function qdb_get_program_session(PDO $pdo, string $sessionID): ?array { + $stmt = $pdo->prepare( + 'SELECT sessionID, name, description, sessionNumber, orderIndex, isActive, createdAt, updatedAt + FROM program_session WHERE sessionID = :sid' + ); + $stmt->execute([':sid' => $sessionID]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + $row['isActive'] = (int)$row['isActive']; + $row['sessionNumber'] = (int)$row['sessionNumber']; + $row['orderIndex'] = (int)$row['orderIndex']; + $row['questionnaires'] = qdb_program_session_questionnaires($pdo, $sessionID); + return $row; +} + +/** + * @return list + */ +function qdb_parse_program_session_questionnaires(array $rows): array { + $out = []; + $idx = 0; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $qnID = trim((string)($row['questionnaireID'] ?? '')); + if ($qnID === '') { + continue; + } + $out[] = [ + 'questionnaireID' => $qnID, + 'orderIndex' => (int)($row['orderIndex'] ?? $idx), + ]; + $idx++; + } + return $out; +} + +function qdb_sync_program_session_questionnaires(PDO $pdo, string $sessionID, array $members): void { + $pdo->prepare('DELETE FROM program_session_questionnaire WHERE sessionID = :sid') + ->execute([':sid' => $sessionID]); + if ($members === []) { + return; + } + $ins = $pdo->prepare( + 'INSERT INTO program_session_questionnaire (sessionID, questionnaireID, orderIndex) + VALUES (:sid, :qn, :o)' + ); + foreach ($members as $m) { + $ins->execute([ + ':sid' => $sessionID, + ':qn' => $m['questionnaireID'], + ':o' => $m['orderIndex'], + ]); + } +} + +/** + * @param list $members + */ +function qdb_validate_program_session_questionnaires(PDO $pdo, array $members): ?string { + if ($members === []) { + return null; + } + $chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id'); + foreach ($members as $m) { + $chk->execute([':id' => $m['questionnaireID']]); + if (!$chk->fetch()) { + return 'Unknown questionnaire: ' . $m['questionnaireID']; + } + } + return null; +} + +/** + * @return array{cycleNumber: int, cycleStartedAt: int, updatedAt: int} + */ +function qdb_client_program_cycle_get(PDO $pdo, string $clientCode, bool $create = true): array +{ + $stmt = $pdo->prepare( + 'SELECT cycleNumber, cycleStartedAt, updatedAt FROM client_program_cycle WHERE clientCode = :cc' + ); + $stmt->execute([':cc' => $clientCode]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if ($row) { + return [ + 'cycleNumber' => max(1, (int)$row['cycleNumber']), + 'cycleStartedAt' => (int)$row['cycleStartedAt'], + 'updatedAt' => (int)$row['updatedAt'], + ]; + } + if (!$create) { + return ['cycleNumber' => 1, 'cycleStartedAt' => 0, 'updatedAt' => 0]; + } + $now = time(); + $pdo->prepare( + 'INSERT INTO client_program_cycle (clientCode, cycleNumber, cycleStartedAt, updatedAt) + VALUES (:cc, 1, 0, :t)' + )->execute([':cc' => $clientCode, ':t' => $now]); + return ['cycleNumber' => 1, 'cycleStartedAt' => 0, 'updatedAt' => $now]; +} + +function qdb_program_session_is_complete_for_client( + PDO $pdo, + string $clientCode, + array $session, + int $cycleStartedAt, +): bool { + $questionnaires = $session['questionnaires'] ?? []; + if ($questionnaires === []) { + return true; + } + if ($cycleStartedAt <= 0) { + $stmt = $pdo->prepare( + "SELECT 1 FROM completed_questionnaire + WHERE clientCode = :cc AND questionnaireID = :qn AND status = 'completed' + LIMIT 1" + ); + foreach ($questionnaires as $q) { + $stmt->execute([ + ':cc' => $clientCode, + ':qn' => $q['questionnaireID'], + ]); + if (!$stmt->fetch()) { + return false; + } + } + return true; + } + $stmt = $pdo->prepare( + "SELECT 1 FROM completed_questionnaire + WHERE clientCode = :cc AND questionnaireID = :qn AND status = 'completed' + AND completedAt > :cycleStart + LIMIT 1" + ); + foreach ($questionnaires as $q) { + $stmt->execute([ + ':cc' => $clientCode, + ':qn' => $q['questionnaireID'], + ':cycleStart' => $cycleStartedAt, + ]); + if (!$stmt->fetch()) { + return false; + } + } + return true; +} + +/** + * @return array + */ +function qdb_client_program_progress(PDO $pdo, string $clientCode): array +{ + $settings = qdb_program_session_settings_get($pdo); + $sessions = qdb_list_program_sessions($pdo, true); + $cycle = qdb_client_program_cycle_get($pdo, $clientCode); + $totalSessions = count($sessions); + + if ($totalSessions === 0) { + return [ + 'cycleNumber' => $cycle['cycleNumber'], + 'cycleStartedAt' => (int)$cycle['cycleStartedAt'], + 'totalSessions' => 0, + 'sessionsCompletedInCycle' => 0, + 'currentSessionID' => null, + 'currentSessionNumber' => null, + 'currentSessionName' => null, + 'cycleComplete' => true, + 'programComplete' => true, + 'autoRepeatSessions' => $settings['autoRepeatSessions'], + ]; + } + + $completedCount = 0; + $currentSession = null; + foreach ($sessions as $session) { + if (!qdb_program_session_is_complete_for_client( + $pdo, + $clientCode, + $session, + $cycle['cycleStartedAt'], + )) { + $currentSession = $session; + break; + } + $completedCount++; + } + + $allComplete = $currentSession === null; + $programComplete = $allComplete && !$settings['autoRepeatSessions']; + + return [ + 'cycleNumber' => $cycle['cycleNumber'], + 'cycleStartedAt' => (int)$cycle['cycleStartedAt'], + 'totalSessions' => $totalSessions, + 'sessionsCompletedInCycle' => $completedCount, + 'currentSessionID' => $currentSession['sessionID'] ?? null, + 'currentSessionNumber' => isset($currentSession['sessionNumber']) + ? (int)$currentSession['sessionNumber'] + : null, + 'currentSessionName' => $currentSession['name'] ?? null, + 'cycleComplete' => $allComplete, + 'programComplete' => $programComplete, + 'autoRepeatSessions' => $settings['autoRepeatSessions'], + ]; +} + +/** + * When auto-repeat is enabled and every active session is complete in the current cycle, + * advance to the next cycle so counselors start again at session 1. + */ +function qdb_maybe_advance_program_cycle(PDO $pdo, string $clientCode): bool +{ + $settings = qdb_program_session_settings_get($pdo); + if (!$settings['autoRepeatSessions']) { + return false; + } + + $sessions = qdb_list_program_sessions($pdo, true); + if ($sessions === []) { + return false; + } + + $cycle = qdb_client_program_cycle_get($pdo, $clientCode); + foreach ($sessions as $session) { + if (!qdb_program_session_is_complete_for_client( + $pdo, + $clientCode, + $session, + $cycle['cycleStartedAt'], + )) { + return false; + } + } + + $now = time(); + $pdo->prepare( + 'UPDATE client_program_cycle + SET cycleNumber = cycleNumber + 1, cycleStartedAt = :t, updatedAt = :t + WHERE clientCode = :cc' + )->execute([':cc' => $clientCode, ':t' => $now]); + + return true; +} + +/** + * Mobile-friendly session list (active only, no admin timestamps). + * + * @return array{sessions: list>, settings: array{autoRepeatSessions: bool}} + */ +function qdb_app_program_sessions_payload(PDO $pdo): array +{ + $settings = qdb_program_session_settings_get($pdo); + $sessions = qdb_list_program_sessions($pdo, true); + $out = []; + foreach ($sessions as $s) { + $out[] = [ + 'sessionID' => $s['sessionID'], + 'name' => $s['name'], + 'description' => $s['description'], + 'sessionNumber' => $s['sessionNumber'], + 'orderIndex' => $s['orderIndex'], + 'questionnaires' => array_map(static function (array $q): array { + return [ + 'questionnaireID' => $q['questionnaireID'], + 'orderIndex' => $q['orderIndex'], + ]; + }, $s['questionnaires']), + ]; + } + return [ + 'sessions' => $out, + 'settings' => $settings, + ]; +} + +/** @deprecated Use qdb_app_program_sessions_payload() */ +function qdb_app_program_sessions(PDO $pdo): array +{ + return qdb_app_program_sessions_payload($pdo)['sessions']; +} diff --git a/schema.sql b/schema.sql index 859d397..a2aa208 100644 --- a/schema.sql +++ b/schema.sql @@ -257,3 +257,34 @@ CREATE TABLE IF NOT EXISTS client_scoring_profile_result ( CREATE INDEX IF NOT EXISTS idx_client_profile_result_profile ON client_scoring_profile_result(profileID); +CREATE TABLE IF NOT EXISTS program_session ( + sessionID TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + sessionNumber INTEGER NOT NULL DEFAULT 1, + orderIndex INTEGER NOT NULL DEFAULT 0, + isActive INTEGER NOT NULL DEFAULT 1, + createdAt INTEGER NOT NULL DEFAULT 0, + updatedAt INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS program_session_questionnaire ( + sessionID TEXT NOT NULL, + questionnaireID TEXT NOT NULL, + orderIndex INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (sessionID, questionnaireID), + FOREIGN KEY(sessionID) REFERENCES program_session(sessionID) ON DELETE CASCADE, + FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_program_session_order + ON program_session(orderIndex, sessionNumber); + +CREATE TABLE IF NOT EXISTS client_program_cycle ( + clientCode TEXT NOT NULL PRIMARY KEY, + cycleNumber INTEGER NOT NULL DEFAULT 1, + cycleStartedAt INTEGER NOT NULL DEFAULT 0, + updatedAt INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE +); + diff --git a/tests/Integration/ProgramSessionsTest.php b/tests/Integration/ProgramSessionsTest.php new file mode 100644 index 0000000..a4a3201 --- /dev/null +++ b/tests/Integration/ProgramSessionsTest.php @@ -0,0 +1,194 @@ +adminToken(); + $qnId = $this->fixture()->questionnaireId; + + $create = $this->api()->request('POST', 'program_sessions', [ + 'name' => 'Baseline', + 'description' => 'First visit', + 'sessionNumber' => 1, + 'orderIndex' => 0, + 'isActive' => true, + 'questionnaires' => [ + ['questionnaireID' => $qnId, 'orderIndex' => 0], + ], + ], ['Authorization' => 'Bearer ' . $token]); + + $this->assertApiOk($create); + $sessionID = $create['data']['session']['sessionID'] ?? ''; + $this->assertNotSame('', $sessionID); + $this->assertSame('Baseline', $create['data']['session']['name']); + $this->assertCount(1, $create['data']['session']['questionnaires']); + + $list = $this->api()->request('GET', 'program_sessions', null, [ + 'Authorization' => 'Bearer ' . $token, + ]); + $this->assertApiOk($list); + $this->assertCount(1, $list['data']['sessions']); + $this->assertFalse($list['data']['settings']['autoRepeatSessions'] ?? true); + + $update = $this->api()->request('PUT', 'program_sessions', [ + 'sessionID' => $sessionID, + 'name' => 'Baseline updated', + 'sessionNumber' => 2, + 'questionnaires' => [], + ], ['Authorization' => 'Bearer ' . $token]); + $this->assertApiOk($update); + $this->assertSame('Baseline updated', $update['data']['session']['name']); + $this->assertSame([], $update['data']['session']['questionnaires']); + + $mobile = $this->coachMobileLogin(); + $appRes = $this->api()->request('GET', 'app_questionnaires', null, [ + 'Authorization' => 'Bearer ' . $mobile['token'], + ], ['sessions' => '1']); + $this->assertApiOk($appRes); + $this->assertCount(1, $appRes['data']['sessions']); + $this->assertSame($sessionID, $appRes['data']['sessions'][0]['sessionID']); + $this->assertArrayHasKey('settings', $appRes['data']); + + $delete = $this->api()->request('DELETE', 'program_sessions', [ + 'sessionID' => $sessionID, + ], ['Authorization' => 'Bearer ' . $token]); + $this->assertApiOk($delete); + + $empty = $this->api()->request('GET', 'program_sessions', null, [ + 'Authorization' => 'Bearer ' . $token, + ]); + $this->assertApiOk($empty); + $this->assertSame([], $empty['data']['sessions']); + } + + public function testAutoRepeatAdvancesCycleAfterLastSessionCompleted(): void + { + $adminToken = $this->adminToken(); + $qnId = $this->fixture()->questionnaireId; + $clientCode = $this->fixture()->clientCode; + + $this->assertApiOk($this->api()->request('PATCH', 'program_sessions', [ + 'autoRepeatSessions' => true, + ], ['Authorization' => 'Bearer ' . $adminToken])); + + $session1 = $this->api()->request('POST', 'program_sessions', [ + 'name' => 'Session 1', + 'sessionNumber' => 1, + 'orderIndex' => 0, + 'isActive' => true, + 'questionnaires' => [['questionnaireID' => $qnId, 'orderIndex' => 0]], + ], ['Authorization' => 'Bearer ' . $adminToken]); + $this->assertApiOk($session1); + + $session2 = $this->api()->request('POST', 'program_sessions', [ + 'name' => 'Session 2', + 'sessionNumber' => 2, + 'orderIndex' => 1, + 'isActive' => true, + 'questionnaires' => [['questionnaireID' => $qnId, 'orderIndex' => 0]], + ], ['Authorization' => 'Bearer ' . $adminToken]); + $this->assertApiOk($session2); + + $login = $this->coachMobileLogin(); + $submit = $this->submitQuestionnaireAs( + $login['token'], + $qnId, + $clientCode, + [['questionID' => $this->fixture()->questionShortId, 'answerOptionKey' => 'yes']], + ); + $this->assertApiOk($submit); + $this->assertTrue($submit['data']['cycleAdvanced'] ?? false); + $this->assertSame(2, $submit['data']['cycleNumber'] ?? 0); + + $clientsRes = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [ + 'clients' => '1', + ]); + $this->assertApiOk($clientsRes); + $plain = qdb_decrypt_sensitive_envelope($clientsRes['data'], $login['token']); + $payload = json_decode($plain, true, 512, JSON_THROW_ON_ERROR); + $client = null; + foreach ($payload['clients'] as $row) { + if (($row['clientCode'] ?? '') === $clientCode) { + $client = $row; + break; + } + } + $this->assertNotNull($client); + $progress = $client['programProgress']; + $this->assertSame(2, $progress['cycleNumber']); + $this->assertSame(2, $progress['totalSessions']); + $this->assertSame(0, $progress['sessionsCompletedInCycle']); + $this->assertTrue($progress['autoRepeatSessions']); + $this->assertFalse($progress['programComplete']); + } + + public function testAutoRepeatDisabledKeepsProgramComplete(): void + { + $adminToken = $this->adminToken(); + $qnId = $this->fixture()->questionnaireId; + $clientCode = $this->fixture()->clientCode; + + $this->assertApiOk($this->api()->request('PATCH', 'program_sessions', [ + 'autoRepeatSessions' => false, + ], ['Authorization' => 'Bearer ' . $adminToken])); + + $this->assertApiOk($this->api()->request('POST', 'program_sessions', [ + 'name' => 'Only session', + 'sessionNumber' => 1, + 'orderIndex' => 0, + 'isActive' => true, + 'questionnaires' => [['questionnaireID' => $qnId, 'orderIndex' => 0]], + ], ['Authorization' => 'Bearer ' . $adminToken])); + + $login = $this->coachMobileLogin(); + $submit = $this->submitQuestionnaireAs( + $login['token'], + $qnId, + $clientCode, + [['questionID' => $this->fixture()->questionShortId, 'answerOptionKey' => 'yes']], + ); + $this->assertApiOk($submit); + $this->assertFalse($submit['data']['cycleAdvanced'] ?? true); + $this->assertSame(1, $submit['data']['cycleNumber'] ?? 0); + + $clientsRes = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [ + 'clients' => '1', + ]); + $this->assertApiOk($clientsRes); + $plain = qdb_decrypt_sensitive_envelope($clientsRes['data'], $login['token']); + $payload = json_decode($plain, true, 512, JSON_THROW_ON_ERROR); + $progress = $payload['clients'][0]['programProgress']; + $this->assertTrue($progress['cycleComplete']); + $this->assertTrue($progress['programComplete']); + $this->assertFalse($progress['autoRepeatSessions']); + } + + public function testRejectUnknownQuestionnaire(): void + { + $token = $this->adminToken(); + $res = $this->api()->request('POST', 'program_sessions', [ + 'name' => 'Invalid', + 'questionnaires' => [ + ['questionnaireID' => 'does-not-exist'], + ], + ], ['Authorization' => 'Bearer ' . $token]); + $this->assertApiError($res, 'INVALID_FIELD', 400); + } + + public function testCoachCannotCreateSession(): void + { + $mobile = $this->coachMobileLogin(); + $res = $this->api()->request('POST', 'program_sessions', [ + 'name' => 'Blocked', + ], ['Authorization' => 'Bearer ' . $mobile['token']]); + $this->assertApiError($res, 'FORBIDDEN', 403); + } +} diff --git a/tests/Unit/ProgramSessionsTest.php b/tests/Unit/ProgramSessionsTest.php new file mode 100644 index 0000000..6e03af7 --- /dev/null +++ b/tests/Unit/ProgramSessionsTest.php @@ -0,0 +1,55 @@ +prepare( + 'INSERT INTO program_session + (sessionID, name, description, sessionNumber, orderIndex, isActive, createdAt, updatedAt) + VALUES (:id, :name, "", 1, 0, 1, :t, :t)' + )->execute([':id' => $sessionID, ':name' => 'S1', ':t' => $now]); + qdb_sync_program_session_questionnaires($pdo, $sessionID, [ + ['questionnaireID' => 'qn-test', 'orderIndex' => 0], + ]); + qdb_save($tmpDb, $lockFp); + } + + public function testAdvanceCycleWhenSessionComplete(): void + { + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + require_once dirname(__DIR__, 2) . '/lib/program_sessions.php'; + qdb_program_session_settings_save($pdo, ['autoRepeatSessions' => true]); + + $now = time(); + $pdo->prepare( + "INSERT INTO completed_questionnaire + (clientCode, questionnaireID, status, completedAt, sumPoints) + VALUES ('CLIENT-TEST-001', 'qn-test', 'completed', :t, 0)" + )->execute([':t' => $now]); + + $this->assertTrue(qdb_maybe_advance_program_cycle($pdo, 'CLIENT-TEST-001')); + $cycle = qdb_client_program_cycle_get($pdo, 'CLIENT-TEST-001'); + $this->assertSame(2, $cycle['cycleNumber']); + qdb_save($tmpDb, $lockFp); + } +} diff --git a/website/index.html b/website/index.html index 853cf32..336e596 100644 --- a/website/index.html +++ b/website/index.html @@ -23,6 +23,12 @@ Overview +
  • + + + Sessions + +
  • diff --git a/website/js/app.js b/website/js/app.js index c5fb1a5..d051401 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -13,6 +13,7 @@ import { translationsPage } from './pages/translations.js'; import { devPage } from './pages/dev.js'; import { insightsPage } from './pages/insights.js'; import { coachesPage } from './pages/coaches.js'; +import { sessionsPage } from './pages/sessions.js'; import { openChangeOwnPasswordModal } from './password-modal.js'; // Auth state @@ -132,6 +133,9 @@ function updateNav() { if (!active && href === '/questionnaires' && hash.startsWith('/questionnaire')) { active = true; } + if (!active && href === '/sessions' && hash.startsWith('/sessions')) { + active = true; + } if (!active && href !== '/' && hash.startsWith(href)) { active = true; } @@ -155,6 +159,7 @@ function roleGuard(roles, handler) { addRoute('/login', loginPage); addRoute('/', roleGuard(['admin', 'supervisor'], homeDashboardPage)); addRoute('/questionnaires', authGuard(questionnairesPage)); +addRoute('/sessions', roleGuard(['admin', 'supervisor'], sessionsPage)); addRoute('/questionnaire/new', authGuard(editorPage)); addRoute('/questionnaire/:id/results', authGuard(resultsPage)); addRoute('/questionnaire/:id', authGuard(editorPage)); diff --git a/website/js/pages/home.js b/website/js/pages/home.js index 8d30e2a..facc66f 100644 --- a/website/js/pages/home.js +++ b/website/js/pages/home.js @@ -35,6 +35,7 @@ function renderHomeDashboard(ov, isAdmin) { `).join(''); const links = [ + { href: '#/sessions', label: 'Sessions', desc: 'Assign session measures to program visits' }, { href: '#/questionnaires', label: 'Session measures', desc: 'Edit instruments, order, and categories' }, { href: '#/clients', label: 'Clients', desc: 'Client codes, counselors, response history' }, { href: '#/coaches', label: 'Counselor activity', desc: 'Uploads and workload per counselor' }, diff --git a/website/js/pages/sessions.js b/website/js/pages/sessions.js new file mode 100644 index 0000000..177ed91 --- /dev/null +++ b/website/js/pages/sessions.js @@ -0,0 +1,354 @@ +import { apiGet, apiPost, apiPut, apiDelete, apiPatch } from '../api.js'; +import { canEdit, pageHeaderHTML, showToast } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; + +export async function sessionsPage() { + const app = document.getElementById('app'); + app.innerHTML = ` + ${pageHeaderHTML('Sessions', '', { + subtitle: 'Define which session measures belong to each program session', + })} +
    + `; + + if (canEdit()) { + document.getElementById('sessionHeaderActions').innerHTML = + ''; + } + + try { + const [sessionsData, questionnairesData] = await Promise.all([ + apiGet('program_sessions'), + apiGet('questionnaires'), + ]); + renderSessions( + sessionsData.sessions || [], + questionnairesData.questionnaires || [], + sessionsData.settings || {}, + ); + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('sessionsContent').innerHTML = + `

    ${esc(e.message)}

    `; + } +} + +function renderSessions(sessions, questionnaires, settings) { + const el = document.getElementById('sessionsContent'); + const sorted = [...sessions].sort((a, b) => + (a.orderIndex ?? 0) - (b.orderIndex ?? 0) + || (a.sessionNumber ?? 0) - (b.sessionNumber ?? 0) + || String(a.name).localeCompare(String(b.name)), + ); + const autoRepeat = !!settings.autoRepeatSessions; + + el.innerHTML = ` +
    +
    +
    +

    Cycle behavior

    +

    + When enabled, completing every active session starts a new cycle at session 1. + Counselors re-complete the assigned session measures for the next cycle. +

    +
    +
    + ${canEdit() ? ` + + ` : ` +

    + Auto-repeat is ${autoRepeat ? 'enabled' : 'disabled'}. +

    + `} +
    +
    +
    +
    +
    +

    Program sessions

    +

    + Counselors see these sessions in the app. Assign one or more session measures to each visit slot. +

    +
    +
    +
    + ${sorted.length + ? sorted.map(s => sessionCardHTML(s, questionnaires)).join('') + : '

    No sessions defined yet. Create one to group session measures by visit.

    '} +
    +
    +
    `; + + if (!canEdit()) return; + + document.getElementById('autoRepeatSessionsToggle')?.addEventListener('change', async (e) => { + const enabled = e.target.checked; + try { + await apiPatch('program_sessions', { autoRepeatSessions: enabled }); + showToast(enabled ? 'Auto-repeat enabled' : 'Auto-repeat disabled', 'success'); + } catch (err) { + e.target.checked = !enabled; + showToast(err.message, 'error'); + } + }); + + document.getElementById('newSessionBtn')?.addEventListener('click', () => { + openSessionModal(null, questionnaires, sorted); + }); + + el.querySelectorAll('.edit-session-btn').forEach(btn => { + btn.addEventListener('click', () => { + const session = sorted.find(x => x.sessionID === btn.dataset.id); + openSessionModal(session, questionnaires, sorted); + }); + }); + + el.querySelectorAll('.delete-session-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!(await confirmAction({ + title: 'Delete session', + message: `Delete session "${btn.dataset.name}"?`, + confirmLabel: 'Delete', + variant: 'danger', + }))) return; + try { + await apiDelete('program_sessions', { sessionID: btn.dataset.id }); + showToast('Session deleted', 'success'); + await sessionsPage(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + }); +} + +function sessionCardHTML(session, questionnaires) { + const qnById = Object.fromEntries((questionnaires || []).map(q => [q.questionnaireID, q])); + const members = session.questionnaires || []; + const activeBadge = session.isActive + ? 'Active' + : 'Inactive'; + const memberRows = members.map(m => { + const q = qnById[m.questionnaireID]; + const name = q?.name || m.name || m.questionnaireID; + return `
    ${esc(name)}
    `; + }).join(''); + + return ` +
    +
    +
    +
    + ${esc(session.name)} + ${activeBadge} +
    +

    Session ${session.sessionNumber} · Order ${session.orderIndex}

    + ${session.description + ? `

    ${esc(session.description)}

    ` + : ''} +
    + ${canEdit() ? ` +
    + + +
    ` : ''} +
    +

    ${members.length} session measure(s)

    +
    ${memberRows + || '

    No session measures assigned

    '}
    +
    `; +} + +function openSessionModal(session, questionnaires, allSessions) { + const isEdit = !!session; + const members = session?.questionnaires?.length + ? session.questionnaires.map(m => ({ + questionnaireID: m.questionnaireID, + orderIndex: m.orderIndex ?? 0, + })) + : []; + const selectedIds = new Set(members.map(m => m.questionnaireID)); + const defaultOrder = allSessions?.length + ? Math.max(...allSessions.map(s => s.orderIndex ?? 0)) + 1 + : 0; + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + document.body.classList.add('modal-open'); + + const picker = overlay.querySelector('#sessQnPicker'); + const sortedQn = [...(questionnaires || [])].sort((a, b) => + (a.orderIndex ?? 0) - (b.orderIndex ?? 0), + ); + + function renderPicker() { + const ordered = members.map(m => ({ + ...m, + q: sortedQn.find(q => q.questionnaireID === m.questionnaireID), + })); + const available = sortedQn.filter(q => !selectedIds.has(q.questionnaireID)); + + picker.innerHTML = ` + ${ordered.map((m, idx) => ` +
    + ${esc(m.q?.name || m.questionnaireID)} + + + + + +
    + `).join('')} + ${available.length ? ` +
    + + +
    ` : ''}`; + + picker.querySelectorAll('.sess-remove').forEach(btn => { + btn.addEventListener('click', () => { + const row = btn.closest('.scoring-profile-q-row'); + const id = row?.dataset.qn; + if (!id) return; + selectedIds.delete(id); + const i = members.findIndex(m => m.questionnaireID === id); + if (i >= 0) members.splice(i, 1); + renderPicker(); + }); + }); + picker.querySelectorAll('.sess-move-up').forEach(btn => { + btn.addEventListener('click', () => { + const row = btn.closest('.scoring-profile-q-row'); + const id = row?.dataset.qn; + const i = members.findIndex(m => m.questionnaireID === id); + if (i > 0) { + [members[i - 1], members[i]] = [members[i], members[i - 1]]; + renderPicker(); + } + }); + }); + picker.querySelectorAll('.sess-move-down').forEach(btn => { + btn.addEventListener('click', () => { + const row = btn.closest('.scoring-profile-q-row'); + const id = row?.dataset.qn; + const i = members.findIndex(m => m.questionnaireID === id); + if (i >= 0 && i < members.length - 1) { + [members[i], members[i + 1]] = [members[i + 1], members[i]]; + renderPicker(); + } + }); + }); + const addBtn = overlay.querySelector('#sessAddBtn'); + if (addBtn) { + const freshAddBtn = addBtn.cloneNode(true); + addBtn.replaceWith(freshAddBtn); + freshAddBtn.addEventListener('click', () => { + const sel = overlay.querySelector('#sessAddSelect'); + const id = sel?.value; + if (!id || selectedIds.has(id)) return; + selectedIds.add(id); + members.push({ questionnaireID: id, orderIndex: members.length }); + renderPicker(); + }); + } + } + + renderPicker(); + + const close = () => { + overlay.remove(); + document.body.classList.remove('modal-open'); + }; + overlay.querySelector('#sessCancel')?.addEventListener('click', close); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + + overlay.querySelector('#sessSave')?.addEventListener('click', async () => { + const name = overlay.querySelector('#sessName')?.value.trim(); + const description = overlay.querySelector('#sessDesc')?.value.trim() || ''; + const sessionNumber = parseInt(overlay.querySelector('#sessNumber')?.value || '1', 10); + const orderIndex = parseInt(overlay.querySelector('#sessOrder')?.value || '0', 10); + const isActive = overlay.querySelector('#sessActive')?.checked; + if (!name) { + showToast('Session name is required', 'error'); + return; + } + const payload = { + name, + description, + sessionNumber: Number.isFinite(sessionNumber) ? Math.max(1, sessionNumber) : 1, + orderIndex: Number.isFinite(orderIndex) ? Math.max(0, orderIndex) : 0, + isActive, + questionnaires: members.map((m, idx) => ({ + questionnaireID: m.questionnaireID, + orderIndex: idx, + })), + }; + try { + if (isEdit) { + await apiPut('program_sessions', { sessionID: session.sessionID, ...payload }); + showToast('Session saved', 'success'); + } else { + await apiPost('program_sessions', payload); + showToast('Session created', 'success'); + } + close(); + await sessionsPage(); + } catch (err) { + showToast(err.message, 'error'); + } + }); +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +}