import { apiGet, apiPost, apiDownloadFetch } from '../api.js'; import { getRole, pageHeaderHTML, showToast } from '../app.js'; import { navigate } from '../router.js'; const ACTIVITY_LABELS = { app_sync: 'App sync', app_change: 'App change', web_change: 'Website change', web_export: 'Website export', }; export function devPage() { if (getRole() !== 'admin') { navigate('#/'); return; } const app = document.getElementById('app'); const today = new Date().toISOString().slice(0, 10); app.innerHTML = ` ${pageHeaderHTML('Admin Settings')}

Export all response data (ZIP)

Download one CSV per questionnaire in a single ZIP (full server data). Current data uses live answers; All versions includes every upload (one row per submission).

Dev import

Local / test only. Imports prefixed dev users (dev_*), clients (DEV-CL-*), and completed questionnaire runs. Existing real users are not modified. Conflicts are skipped (usernames, client codes, completions). Default password: socialvrlab. Questionnaires must already exist in the database.

Use dev-test-fixture.json from the repo root (generate via nat-as-server/scripts/generate_dev_fixture.py; uneven coach load, multi-version uploads, natural timestamps).

Reset database

Destructive. Deletes every client, completion, coach, and supervisor (including non-dev accounts). Admin logins and questionnaire definitions are kept.

API activity log

App syncs, website edits (translations, users, questionnaires, …), and exports/downloads. Routine page loads (lists, dashboards) are not logged.

Time (UTC) Kind Subject Method Route Status Duration Values Performed by
Loading…
`; let parsedFixture = null; document.getElementById('devFixtureFile').addEventListener('change', async (e) => { const file = e.target.files?.[0]; const summary = document.getElementById('devFixtureSummary'); const btn = document.getElementById('devImportBtn'); parsedFixture = null; btn.disabled = true; summary.style.display = 'none'; if (!file) return; try { const text = await file.text(); parsedFixture = JSON.parse(text); const st = parsedFixture.stats || {}; summary.innerHTML = ` Preview: ${parsedFixture.admins?.length ?? st.admins ?? 0} admins, ${parsedFixture.supervisors?.length ?? st.supervisors ?? 0} supervisors, ${parsedFixture.coaches?.length ?? st.coaches ?? 0} coaches, ${parsedFixture.clients?.length ?? st.clients ?? 0} clients, ${parsedFixture.completions?.length ?? st.completionRecords ?? st.completions ?? 0} completions, ${st.totalUploads ?? '—'} uploads (${st.multiVersionPairs ?? '—'} re-uploaded) `; summary.style.display = ''; btn.disabled = false; } catch (err) { showToast('Invalid JSON: ' + err.message, 'error'); } }); document.getElementById('devRemoveBtn').addEventListener('click', async () => { if (!confirm( 'Remove all dev test data?\n\n' + 'Deletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\n' + 'Real accounts are not affected.' )) { return; } const btn = document.getElementById('devRemoveBtn'); const pre = document.getElementById('devImportResult'); btn.disabled = true; btn.textContent = 'Removing…'; pre.style.display = 'none'; try { const res = await apiPost('dev', { action: 'remove', usernamePrefix: 'dev_', clientCodePrefix: 'DEV-CL-', }); if (!res.success) { showToast(res.error || res.message || 'Remove failed', 'error'); return; } pre.textContent = JSON.stringify(res, null, 2); pre.style.display = ''; const d = res.deleted || {}; showToast( `Removed ${d.clients ?? 0} clients, ${d.users ?? 0} users, ` + `${d.completed_questionnaires ?? 0} completions`, 'success' ); } catch (e) { showToast(e.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Remove test data'; } }); document.getElementById('devWipeBtn').addEventListener('click', async () => { if (!confirm( 'Remove ALL clients, completions, coaches, and supervisors?\n\n' + 'Admin accounts and questionnaires will NOT be deleted.\n' + 'This cannot be undone.' )) { return; } const btn = document.getElementById('devWipeBtn'); const pre = document.getElementById('devImportResult'); btn.disabled = true; btn.textContent = 'Removing…'; pre.style.display = 'none'; try { const res = await apiPost('dev', { action: 'wipeExceptAdmins' }); if (!res.success) { showToast(res.error || res.message || 'Wipe failed', 'error'); return; } pre.textContent = JSON.stringify(res, null, 2); pre.style.display = ''; const d = res.deleted || {}; const kept = res.kept?.admins ?? '?'; showToast( `Wiped ${d.clients ?? 0} clients, ${d.users ?? 0} users, ` + `${d.completed_questionnaires ?? 0} completions (${kept} admins kept)`, 'success' ); } catch (e) { showToast(e.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Remove all data (keep admins)'; } }); wireAdminZipExport(); wireActivityLog(); document.getElementById('devImportBtn').addEventListener('click', async () => { if (!parsedFixture) return; const btn = document.getElementById('devImportBtn'); const pre = document.getElementById('devImportResult'); btn.disabled = true; btn.textContent = 'Importing…'; pre.style.display = 'none'; try { const res = await apiPost('dev', { fixture: parsedFixture }); if (!res.success) { showToast(res.error || 'Import failed', 'error'); return; } pre.textContent = JSON.stringify(res, null, 2); pre.style.display = ''; const imp = res.imported || {}; const sk = res.skipped || {}; showToast( `Imported: ${imp.completions ?? 0} completions, ${imp.submissions ?? 0} uploads, ` + `${imp.clients ?? 0} clients, ${imp.coaches ?? 0} coaches ` + `(skipped ${sk.completions ?? 0} existing)`, 'success' ); if (res.errors?.length) { showToast(`${res.errors.length} warning(s) — see log below`, 'error'); } } catch (e) { showToast(e.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Import fixture'; } }); } function renderActivityLogStatus(status) { const el = document.getElementById('activityLogStatus'); if (!el || !status) { if (el) el.style.display = 'none'; return; } if (!status.enabled) { el.className = 'field-hint callout-warn'; el.innerHTML = 'Activity logging is disabled (QDB_API_LOG=0 in .env).'; el.style.display = ''; return; } if (status.writable) { el.className = 'field-hint'; el.innerHTML = `Logging to ${escHtml(status.dir)}` + (status.php_user ? ` (PHP user: ${escHtml(status.php_user)})` : ''); el.style.display = ''; return; } el.className = 'field-hint callout-danger'; el.innerHTML = `Cannot write activity log. ${escHtml(status.error || 'Permission problem.')}
Path: ${escHtml(status.dir)}` + (status.php_user ? `
PHP runs as: ${escHtml(status.php_user)}` : '') + (status.fix_hint ? `
${escHtml(status.fix_hint)}` : ''); el.style.display = ''; } function wireActivityLog() { const dateEl = document.getElementById('activityLogDate'); const kindEl = document.getElementById('activityLogKind'); document.getElementById('activityLogRefresh')?.addEventListener('click', loadActivityLog); dateEl?.addEventListener('change', loadActivityLog); kindEl?.addEventListener('change', loadActivityLog); loadActivityLog(); } async function loadActivityLog() { const tbody = document.getElementById('activityLogBody'); const meta = document.getElementById('activityLogMeta'); if (!tbody) return; const date = document.getElementById('activityLogDate')?.value || new Date().toISOString().slice(0, 10); const activity = document.getElementById('activityLogKind')?.value || ''; tbody.innerHTML = 'Loading…'; try { const q = new URLSearchParams({ date, limit: '300' }); if (activity) q.set('activity', activity); const data = await apiGet(`activity-log?${q}`); if (!data.success) throw new Error(data.error || 'Failed to load activity log'); renderActivityLogStatus(data.logStatus); const entries = data.entries || []; const files = (data.files || []).join(', ') || 'none'; const trunc = data.truncated ? ' (newest 300 shown)' : ''; if (meta) { meta.textContent = entries.length ? `${entries.length} entries from ${files}${trunc}` : `No entries for this date/filter (files: ${files})`; } if (!entries.length) { tbody.innerHTML = 'No activity recorded'; return; } tbody.innerHTML = entries.map((e) => { const kind = e.activity || ''; const label = ACTIVITY_LABELS[kind] || kind || '—'; const badgeClass = kind ? `badge-activity-${kind}` : ''; const time = formatActivityTime(e.ts); const status = e.status != null ? String(e.status) : '—'; const dur = e.duration_ms != null ? `${e.duration_ms} ms` : '—'; const subject = formatActivitySubject(e); const performedBy = formatActivityPerformer(e); const errPart = e.error ? `
${escHtml(e.error)}
` : ''; return ` ${escHtml(time)} ${escHtml(label)} ${subject} ${escHtml(e.method || '')} ${escHtml(e.route || '')} ${escHtml(status)} ${escHtml(dur)} ${renderActivityChanges(e.changes)} ${performedBy}${errPart} `; }).join(''); } catch (err) { if (meta) meta.textContent = ''; tbody.innerHTML = `${escHtml(err.message)}`; } } function formatActivitySubject(entry) { if (entry.target_username) { return `${escHtml(entry.target_username)}`; } const changes = entry.changes || []; const userChange = changes.find((c) => (c.field || '').toLowerCase() === 'username'); if (userChange) { const name = userChange.display || formatActivityChangeValue(userChange.value); return `${escHtml(name)}`; } const client = changes.find((c) => (c.field || '').toLowerCase().includes('clientcode')); if (client) { const code = client.display || formatActivityChangeValue(client.value); return `client ${escHtml(code)}`; } const qClient = changes.find((c) => (c.field || '') === 'query.clientCode'); if (qClient) { return `client ${escHtml(formatActivityChangeValue(qClient.value))}`; } return ''; } function formatActivityPerformer(entry) { if (entry.actor_username) { const role = entry.role ? ` (${escHtml(entry.role)})` : ''; return `${escHtml(entry.actor_username)}${role}`; } if (entry.role) { return escHtml(entry.role); } return '—'; } function renderActivityChanges(changes) { const list = Array.isArray(changes) ? changes : []; if (!list.length) { return ''; } const rows = list.map((c) => { const field = c.field ?? ''; const raw = formatActivityChangeValue(c.value); const display = (c.display != null && String(c.display) !== '') ? String(c.display) : raw; const showRaw = display !== raw && !raw.startsWith('[REDACTED]') && !raw.startsWith('[ENCRYPTED'); return ` ${escHtml(field)} ${escHtml(display)} ${showRaw ? `
${escHtml(raw)}
` : ''} `; }).join(''); return `${rows}
`; } function formatActivityChangeValue(value) { if (value === null || value === undefined) return 'null'; if (typeof value === 'boolean') return value ? 'true' : 'false'; return String(value); } function formatActivityTime(iso) { if (!iso) return '—'; try { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; return d.toISOString().replace('T', ' ').slice(0, 19); } catch { return iso; } } function escHtml(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; } function filenameFromContentDisposition(res, fallback) { const disp = res.headers.get('Content-Disposition'); if (!disp) return fallback; const m = /filename="([^"]+)"/i.exec(disp); return m ? m[1] : fallback; } async function downloadExportZip(url, defaultFilename) { const res = await apiDownloadFetch(url); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Download failed' })); throw new Error(err.error?.message || err.error || 'Download failed'); } const blob = await res.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = filenameFromContentDisposition(res, defaultFilename); a.click(); URL.revokeObjectURL(blobUrl); } function wireAdminZipExport() { document.getElementById('exportAllCurrentZipBtn')?.addEventListener('click', async () => { const btn = document.getElementById('exportAllCurrentZipBtn'); btn.disabled = true; const prev = btn.textContent; btn.textContent = 'Preparing ZIP…'; try { await downloadExportZip('../api/export?exportAll=1', 'responses_export_current.zip'); showToast('ZIP download started (current data)', 'success'); } catch (e) { showToast(e.message, 'error'); } finally { btn.disabled = false; btn.textContent = prev; } }); document.getElementById('exportAllVersionsZipBtn')?.addEventListener('click', async () => { const btn = document.getElementById('exportAllVersionsZipBtn'); btn.disabled = true; const prev = btn.textContent; btn.textContent = 'Preparing ZIP…'; try { await downloadExportZip('../api/export?exportAll=1&allVersions=1', 'responses_export_all_versions.zip'); showToast('ZIP download started (all versions)', 'success'); } catch (e) { showToast(e.message, 'error'); } finally { btn.disabled = false; btn.textContent = prev; } }); }