session cylces and adjustments
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-06-29 13:26:08 +02:00
parent f1caa9e681
commit 7fefc264f4
12 changed files with 1256 additions and 3 deletions

View File

@ -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',

View File

@ -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)) {

View File

@ -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,
'completedQuestionnaires' => $completions[$code] ?? [],
'programProgress' => qdb_client_program_progress($pdo, $code),
];
}, $clientCodes);

View 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
View 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'];
}

View File

@ -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
);

View 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);
}
}

View 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);
}
}

View File

@ -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>

View File

@ -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));

View File

@ -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' },

View 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;
}