Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ lerna-debug.log*
.env.production.local
*.env

# IDE / Editor folders
# IDE / Editor / assistant folders
.vscode/
.idea/
.claude/
*.suo
*.ntvs*
*.njsproj
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "1.0.0",
"description": "A beautiful, premium Clinical Simulation Scenario Storage & running system designed to align with ASPiH standards.",
"main": "server.js",
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"start": "node server.js",
"seed": "node seed.js",
Expand Down
36 changes: 0 additions & 36 deletions public/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1289,22 +1289,6 @@ main {
animation: modalIn 0.4s cubic-bezier(0.21, 1.02, 0.73, 1);
}

/* Demo accounts behind a quiet disclosure */
.login-credentials-helper summary {
cursor: pointer;
color: var(--text-muted);
font-size: 0.8rem;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 6px;
transition: color var(--transition-fast);
}
.login-credentials-helper summary::-webkit-details-marker { display: none; }
.login-credentials-helper summary:hover { color: var(--text-secondary); }
.login-credentials-helper[open] summary { margin-bottom: 10px; }

.login-logo {
display: flex;
flex-direction: column;
Expand All @@ -1325,26 +1309,6 @@ main {
font-size: 1.8rem;
}

.login-credentials-helper {
margin-top: 24px;
padding: 16px;
background: rgba(255, 255, 255, 0.02);
border-radius: var(--border-radius-sm);
border: 1px solid var(--glass-border);
font-size: 0.8rem;
}

.login-credentials-helper h5 {
margin-bottom: 8px;
color: var(--text-secondary);
}

.login-credentials-helper p {
color: var(--text-muted);
font-family: var(--font-mono);
margin-bottom: 4px;
}

/* Toast notifications: stacked, animated, auto-dismiss progress bar */
#toast-container {
position: fixed;
Expand Down
46 changes: 21 additions & 25 deletions public/js/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,14 @@ const components = {
allScenarios: [],
allProgrammes: [],
currentFormTab: 'tab-general',


// Ordered steps of the scenario editor wizard.
EDITOR_TABS: ['tab-general', 'tab-outcomes', 'tab-patient', 'tab-setup', 'tab-progression', 'tab-debrief', 'tab-aspih'],

// Interactive Run State
runState: {
scenario: null,
activePhaseIndex: 0,
startTime: null,
elapsedSeconds: 0,
timerInterval: null,
timerRunning: false,
Expand All @@ -51,8 +53,7 @@ const components = {
scenario: null,
currentStep: 'reactions', // 'reactions', 'description', 'analysis', 'summary'
elapsedSeconds: 0,
timerInterval: null,
timerRunning: false
timerInterval: null
},

// Initialize and load data
Expand Down Expand Up @@ -1163,8 +1164,7 @@ const components = {
this.currentFormTab = tabId;

// Stepper states: current step is active, earlier steps show completed
const stepList = ['tab-general', 'tab-outcomes', 'tab-patient', 'tab-setup', 'tab-progression', 'tab-debrief', 'tab-aspih'];
const currentIdx = stepList.indexOf(tabId);
const currentIdx = this.EDITOR_TABS.indexOf(tabId);
const tabs = document.querySelectorAll('#editor-tabs-container .editor-tab');
tabs.forEach((tab, i) => {
tab.classList.toggle('active', i === currentIdx);
Expand All @@ -1186,17 +1186,14 @@ const components = {
// Update wizard button displays
const prevBtn = document.getElementById('btn-prev-tab');
const nextBtn = document.getElementById('btn-next-tab');

const tabList = ['tab-general', 'tab-outcomes', 'tab-patient', 'tab-setup', 'tab-progression', 'tab-debrief', 'tab-aspih'];
const idx = tabList.indexOf(tabId);

if (idx === 0) {
if (currentIdx === 0) {
prevBtn.style.visibility = 'hidden';
} else {
prevBtn.style.visibility = 'visible';
}

if (idx === tabList.length - 1) {
if (currentIdx === this.EDITOR_TABS.length - 1) {
nextBtn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Save Scenario';
nextBtn.onclick = () => this.saveScenario();
} else {
Expand All @@ -1206,18 +1203,16 @@ const components = {
},

nextEditorTab() {
const tabList = ['tab-general', 'tab-outcomes', 'tab-patient', 'tab-setup', 'tab-progression', 'tab-debrief', 'tab-aspih'];
const idx = tabList.indexOf(this.currentFormTab);
if (idx < tabList.length - 1) {
this.switchEditorTab(tabList[idx + 1]);
const idx = this.EDITOR_TABS.indexOf(this.currentFormTab);
if (idx < this.EDITOR_TABS.length - 1) {
this.switchEditorTab(this.EDITOR_TABS[idx + 1]);
}
},

prevEditorTab() {
const tabList = ['tab-general', 'tab-outcomes', 'tab-patient', 'tab-setup', 'tab-progression', 'tab-debrief', 'tab-aspih'];
const idx = tabList.indexOf(this.currentFormTab);
const idx = this.EDITOR_TABS.indexOf(this.currentFormTab);
if (idx > 0) {
this.switchEditorTab(tabList[idx - 1]);
this.switchEditorTab(this.EDITOR_TABS[idx - 1]);
}
},

Expand Down Expand Up @@ -2926,8 +2921,7 @@ const components = {
this.debriefState.scenario = s;
this.debriefState.currentStep = 'reactions';
this.debriefState.elapsedSeconds = 0;
this.debriefState.timerRunning = false;


if (this.debriefState.timerInterval) clearInterval(this.debriefState.timerInterval);

const container = document.getElementById('debrief-content');
Expand Down Expand Up @@ -3118,7 +3112,6 @@ const components = {
},

startDebriefTimer() {
this.debriefState.timerRunning = true;
this.debriefState.timerInterval = setInterval(() => {
this.debriefState.elapsedSeconds++;
this.updateDebriefTimerDisplay();
Expand Down Expand Up @@ -3407,21 +3400,25 @@ const components = {
app.showToast(`${s.email}: ${s.reason}`, 'error'));
},

// Escape a value for a CSV cell (RFC 4180 double-quote style).
csvQuote(v) {
return `"${String(v).replace(/"/g, '""')}"`;
},

// Download the account list (no credentials) as a CSV file
exportUsersCsv() {
const users = this.adminUsersState.users;
if (!users.length) {
app.showToast('No users loaded to export.', 'error');
return;
}
const quote = v => `"${String(v).replace(/"/g, '""')}"`;
const csv = ['email,name,role,status,created,last_login',
...users.map(u => [
u.email, u.name, u.role,
u.disabled ? 'Disabled' : 'Active',
u.createdAt || '',
u.lastLogin || 'Never'
].map(quote).join(','))].join('\r\n');
].map(v => this.csvQuote(v)).join(','))].join('\r\n');
this.downloadFile(csv, `simhub_users_${new Date().toISOString().slice(0, 10)}.csv`, 'text/csv');
app.showToast('User list exported.', 'success');
},
Expand Down Expand Up @@ -3552,9 +3549,8 @@ const components = {
const dl = document.getElementById('bulk-creds-download');
if (dl) {
dl.addEventListener('click', () => {
const quote = v => `"${String(v).replace(/"/g, '""')}"`;
const csv = ['email,name,role,temporary_password',
...created.map(c => [c.email, c.name, c.role, c.tempPassword || ''].map(quote).join(','))].join('\r\n');
...created.map(c => [c.email, c.name, c.role, c.tempPassword || ''].map(v => this.csvQuote(v)).join(','))].join('\r\n');
this.downloadFile(csv, `simhub_new_accounts_${new Date().toISOString().slice(0, 10)}.csv`, 'text/csv');
});
}
Expand Down
51 changes: 23 additions & 28 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -389,17 +389,23 @@ function editorProgrammeIds(user) {
return Array.isArray(user && user.programmeIds) ? user.programmeIds : [];
}

// Ids of every programme on disk (used to validate allocations).
function existingProgrammeIds() {
const set = new Set();
// Iterate every readable programme file; unreadable files are skipped
// (readJsonSafe logs them). The file path is passed for callers that need
// to write the programme back.
function eachProgramme(cb) {
fs.readdirSync(PROGRAMMES_DIR)
.filter(f => f.endsWith('.json'))
.forEach(f => {
try {
const p = JSON.parse(fs.readFileSync(path.join(PROGRAMMES_DIR, f), 'utf8'));
if (p && p.id) set.add(p.id);
} catch { /* ignore unreadable programme files */ }
const filePath = path.join(PROGRAMMES_DIR, f);
const prog = readJsonSafe(filePath);
if (prog) cb(prog, filePath);
});
}

// Ids of every programme on disk (used to validate allocations).
function existingProgrammeIds() {
const set = new Set();
eachProgramme(p => { if (p.id) set.add(p.id); });
return set;
}

Expand All @@ -418,14 +424,9 @@ function sanitizeProgrammeIds(input) {
// Programme ids whose scenarioIds include the given scenario.
function programmeIdsContainingScenario(scenarioId) {
const ids = new Set();
fs.readdirSync(PROGRAMMES_DIR)
.filter(f => f.endsWith('.json'))
.forEach(f => {
try {
const p = JSON.parse(fs.readFileSync(path.join(PROGRAMMES_DIR, f), 'utf8'));
if (Array.isArray(p.scenarioIds) && p.scenarioIds.includes(scenarioId)) ids.add(p.id);
} catch { /* ignore */ }
});
eachProgramme(p => {
if (Array.isArray(p.scenarioIds) && p.scenarioIds.includes(scenarioId)) ids.add(p.id);
});
return ids;
}

Expand Down Expand Up @@ -738,14 +739,11 @@ app.delete('/api/scenarios/:id', authenticate, requireScenarioAccess, (req, res)
writeJsonAtomic(binPath, data);
fs.unlinkSync(filePath);

// Also remove scenario from any programmes. Per-file failures are logged
// and skipped: the scenario has already moved to the recycle bin, so one
// unreadable programme file must not turn a completed delete into a 500.
const progFiles = fs.readdirSync(PROGRAMMES_DIR);
progFiles.filter(file => file.endsWith('.json')).forEach(file => {
const pPath = path.join(PROGRAMMES_DIR, file);
const prog = readJsonSafe(pPath);
if (prog && prog.scenarioIds && prog.scenarioIds.includes(scenarioId)) {
// Also remove scenario from any programmes. Unreadable programme files
// are logged and skipped: the scenario has already moved to the recycle
// bin, so one bad file must not turn a completed delete into a 500.
eachProgramme((prog, pPath) => {
if (prog.scenarioIds && prog.scenarioIds.includes(scenarioId)) {
prog.scenarioIds = prog.scenarioIds.filter(id => id !== scenarioId);
writeJsonAtomic(pPath, prog);
}
Expand All @@ -763,11 +761,8 @@ app.delete('/api/scenarios/:id', authenticate, requireScenarioAccess, (req, res)
// List programmes
app.get('/api/programmes', authenticate, (req, res) => {
try {
const files = fs.readdirSync(PROGRAMMES_DIR);
const programmes = files
.filter(file => file.endsWith('.json'))
.map(file => readJsonSafe(path.join(PROGRAMMES_DIR, file)))
.filter(Boolean);
const programmes = [];
eachProgramme(p => programmes.push(p));
res.json(programmes);
} catch (err) {
res.status(500).json({ error: 'Failed to retrieve programmes: ' + err.message });
Expand Down
Loading