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',
api_error: 'API error',
};
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).
Export all — current data (ZIP)
Export all — all versions (ZIP)
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.
Fixture JSON
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).
Import fixture
Remove test data
Reset database
Destructive. Deletes every client, completion, coach, and supervisor
(including non-dev accounts). Admin logins and questionnaire definitions are kept.
Remove all data (keep admins)
API activity log
App syncs, website edits (translations, users, questionnaires, …),
and exports/downloads. Routine page loads (lists, dashboards) are not logged.
Date
Activity
All kinds
App sync
App change
Website change
Website export
Errors only (4xx/5xx)
Refresh
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 statusNum = e.status != null ? Number(e.status) : null;
const status = statusNum != null ? String(statusNum) : '—';
const statusClass = statusNum != null && statusNum >= 400 ? 'error-text' : '';
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 ``;
}
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;
}
});
}