This commit is contained in:
@ -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',
|
||||
|
||||
42
db_init.php
42
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)) {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
172
handlers/program_sessions.php
Normal file
172
handlers/program_sessions.php
Normal file
@ -0,0 +1,172 @@
|
||||
<?php
|
||||
/**
|
||||
* Program session CRUD — define which questionnaires belong to each session.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../lib/program_sessions.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
||||
$sessionID = trim($_GET['id'] ?? '');
|
||||
if ($sessionID !== '') {
|
||||
$session = qdb_get_program_session($pdo, $sessionID);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
if (!$session) {
|
||||
json_error('NOT_FOUND', 'Session not found', 404);
|
||||
}
|
||||
json_success([
|
||||
'session' => $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);
|
||||
}
|
||||
377
lib/program_sessions.php
Normal file
377
lib/program_sessions.php
Normal file
@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Program session definitions: ordered visit slots with assigned questionnaires.
|
||||
*/
|
||||
|
||||
const QDB_PROGRAM_SESSIONS_AUTO_REPEAT_KEY = 'program_sessions_auto_repeat';
|
||||
|
||||
/**
|
||||
* @return array{autoRepeatSessions: bool}
|
||||
*/
|
||||
function qdb_program_session_settings_get(PDO $pdo): array
|
||||
{
|
||||
require_once __DIR__ . '/settings.php';
|
||||
qdb_ensure_system_setting_table($pdo);
|
||||
$stmt = $pdo->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<array<string, mixed>>
|
||||
*/
|
||||
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<array<string, mixed>>
|
||||
*/
|
||||
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<array{questionnaireID: string, orderIndex: int}>
|
||||
*/
|
||||
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<array{questionnaireID: string, orderIndex: int}> $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<string, mixed>
|
||||
*/
|
||||
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<array<string, mixed>>, 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'];
|
||||
}
|
||||
31
schema.sql
31
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
|
||||
);
|
||||
|
||||
|
||||
194
tests/Integration/ProgramSessionsTest.php
Normal file
194
tests/Integration/ProgramSessionsTest.php
Normal file
@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class ProgramSessionsTest extends QdbTestCase
|
||||
{
|
||||
public function testAdminCrudAndMobileFetch(): void
|
||||
{
|
||||
$token = $this->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);
|
||||
}
|
||||
}
|
||||
55
tests/Unit/ProgramSessionsTest.php
Normal file
55
tests/Unit/ProgramSessionsTest.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\TestFilesystem;
|
||||
|
||||
final class ProgramSessionsTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
TestFilesystem::resetUploads();
|
||||
if (defined('QDB_PATH') && is_file(QDB_PATH)) {
|
||||
@unlink(QDB_PATH);
|
||||
}
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
DatabaseSeeder::seed($pdo);
|
||||
require_once dirname(__DIR__, 2) . '/lib/program_sessions.php';
|
||||
|
||||
$sessionID = bin2hex(random_bytes(8));
|
||||
$now = time();
|
||||
$pdo->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);
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,12 @@
|
||||
Overview
|
||||
</a>
|
||||
</li>
|
||||
<li data-nav-roles="admin supervisor">
|
||||
<a href="#/sessions">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
Sessions
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#/questionnaires">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/></svg>
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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' },
|
||||
|
||||
354
website/js/pages/sessions.js
Normal file
354
website/js/pages/sessions.js
Normal file
@ -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', '<span id="sessionHeaderActions"></span>', {
|
||||
subtitle: 'Define which session measures belong to each program session',
|
||||
})}
|
||||
<div id="sessionsContent"><div class="spinner"></div></div>
|
||||
`;
|
||||
|
||||
if (canEdit()) {
|
||||
document.getElementById('sessionHeaderActions').innerHTML =
|
||||
'<button type="button" class="btn btn-primary" id="newSessionBtn">+ New session</button>';
|
||||
}
|
||||
|
||||
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 =
|
||||
`<p class="error-text">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="card scoring-profiles-card" style="margin-bottom:16px">
|
||||
<div class="scoring-profile-header">
|
||||
<div>
|
||||
<h3 class="scoring-section-title">Cycle behavior</h3>
|
||||
<p class="scoring-section-desc">
|
||||
When enabled, completing every active session starts a new cycle at session 1.
|
||||
Counselors re-complete the assigned session measures for the next cycle.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
${canEdit() ? `
|
||||
<label class="checkbox-inline scoring-profile-active-row">
|
||||
<input type="checkbox" id="autoRepeatSessionsToggle" ${autoRepeat ? 'checked' : ''}>
|
||||
Automatically repeat sessions when the last session is completed
|
||||
</label>
|
||||
` : `
|
||||
<p class="data-toolbar-hint" style="margin:0">
|
||||
Auto-repeat is ${autoRepeat ? 'enabled' : 'disabled'}.
|
||||
</p>
|
||||
`}
|
||||
</div>
|
||||
<div class="scoring-profiles-section">
|
||||
<div class="card scoring-profiles-card">
|
||||
<div class="scoring-profile-header">
|
||||
<div>
|
||||
<h3 class="scoring-section-title">Program sessions</h3>
|
||||
<p class="scoring-section-desc">
|
||||
Counselors see these sessions in the app. Assign one or more session measures to each visit slot.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sessionsList" class="scoring-profiles-list">
|
||||
${sorted.length
|
||||
? sorted.map(s => sessionCardHTML(s, questionnaires)).join('')
|
||||
: '<p class="scoring-empty-hint">No sessions defined yet. Create one to group session measures by visit.</p>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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
|
||||
? '<span class="badge badge-active">Active</span>'
|
||||
: '<span class="badge badge-draft">Inactive</span>';
|
||||
const memberRows = members.map(m => {
|
||||
const q = qnById[m.questionnaireID];
|
||||
const name = q?.name || m.name || m.questionnaireID;
|
||||
return `<div class="scoring-profile-q-row"><span class="scoring-profile-q-name">${esc(name)}</span></div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="scoring-profile-card">
|
||||
<div class="scoring-profile-card-top">
|
||||
<div class="scoring-profile-card-main">
|
||||
<div class="scoring-profile-name-row">
|
||||
<strong>${esc(session.name)}</strong>
|
||||
${activeBadge}
|
||||
</div>
|
||||
<p class="scoring-profile-bands">Session ${session.sessionNumber} · Order ${session.orderIndex}</p>
|
||||
${session.description
|
||||
? `<p class="scoring-profile-desc">${esc(session.description)}</p>`
|
||||
: ''}
|
||||
</div>
|
||||
${canEdit() ? `
|
||||
<div class="scoring-profile-card-actions">
|
||||
<button type="button" class="btn btn-sm edit-session-btn"
|
||||
data-id="${esc(session.sessionID)}">Edit</button>
|
||||
<button type="button" class="btn btn-sm btn-danger delete-session-btn"
|
||||
data-id="${esc(session.sessionID)}"
|
||||
data-name="${esc(session.name)}">Delete</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<p class="scoring-profile-member-count">${members.length} session measure(s)</p>
|
||||
<div class="scoring-profile-members">${memberRows
|
||||
|| '<p class="scoring-empty-hint">No session measures assigned</p>'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="modal-dialog modal-dialog-wide">
|
||||
<h2>${isEdit ? 'Edit session' : 'New session'}</h2>
|
||||
<p class="modal-subtitle">Name the visit slot and choose which session measures counselors complete during it.</p>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div class="form-group">
|
||||
<label for="sessName">Name</label>
|
||||
<input type="text" id="sessName" value="${esc(session?.name || '')}" placeholder="e.g. Baseline visit">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sessNumber">Session number</label>
|
||||
<input type="number" id="sessNumber" min="1" value="${session?.sessionNumber ?? 1}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div class="form-group">
|
||||
<label for="sessOrder">Display order</label>
|
||||
<input type="number" id="sessOrder" min="0" value="${session?.orderIndex ?? defaultOrder}">
|
||||
</div>
|
||||
<div class="form-group scoring-profile-active-row" style="align-self:end;padding-bottom:8px">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="sessActive" ${session?.isActive !== 0 ? 'checked' : ''}>
|
||||
Active (visible in app)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sessDesc">Description</label>
|
||||
<textarea id="sessDesc" rows="2" placeholder="Optional note for counselors">${esc(session?.description || '')}</textarea>
|
||||
</div>
|
||||
<h4 class="scoring-modal-section-title">Session measures</h4>
|
||||
<p class="data-toolbar-hint" style="margin:0 0 10px">Add questionnaires in the order they should appear for this session.</p>
|
||||
<div id="sessQnPicker" class="scoring-profile-picker"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-sm" id="sessCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="sessSave">${isEdit ? 'Save' : 'Create'}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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) => `
|
||||
<div class="scoring-profile-q-row" data-qn="${esc(m.questionnaireID)}">
|
||||
<span class="scoring-profile-q-name">${esc(m.q?.name || m.questionnaireID)}</span>
|
||||
<span class="scoring-profile-q-actions">
|
||||
<button type="button" class="btn btn-sm sess-move-up" ${idx === 0 ? 'disabled' : ''} title="Move up">↑</button>
|
||||
<button type="button" class="btn btn-sm sess-move-down" ${idx === ordered.length - 1 ? 'disabled' : ''} title="Move down">↓</button>
|
||||
<button type="button" class="btn btn-sm btn-danger sess-remove" title="Remove">Remove</button>
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${available.length ? `
|
||||
<div class="scoring-profile-add-row">
|
||||
<select id="sessAddSelect">
|
||||
<option value="">Add session measure…</option>
|
||||
${available.map(q => `<option value="${esc(q.questionnaireID)}">${esc(q.name)}</option>`).join('')}
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm" id="sessAddBtn">Add</button>
|
||||
</div>` : ''}`;
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user