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).
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
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 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 ``;
}
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;
}
});
}