f96fdcb3b9
Lock health recording behind confirm-only state machines, reject public publishes for the health category, and ship the 14-day experiment Page Data templates with tests. Co-authored-by: Cursor <cursoragent@cursor.com>
119 lines
3.3 KiB
JavaScript
119 lines
3.3 KiB
JavaScript
export const HEALTH_P0_DATASET_NAME = 'health_p0_daily_log';
|
|
|
|
export const HEALTH_P0_INSERT_COLUMNS = Object.freeze([
|
|
'participant_code',
|
|
'log_date',
|
|
'am_measured_at',
|
|
'am_systolic',
|
|
'am_diastolic',
|
|
'am_pulse',
|
|
'pm_measured_at',
|
|
'pm_systolic',
|
|
'pm_diastolic',
|
|
'pm_pulse',
|
|
'sleep_start',
|
|
'sleep_end',
|
|
'sleep_minutes',
|
|
'sleep_source',
|
|
'weight_kg',
|
|
'symptoms',
|
|
'alcohol',
|
|
'stress',
|
|
'medication_change',
|
|
'medication_note',
|
|
'other_note',
|
|
'entry_channel',
|
|
'photo_sent',
|
|
]);
|
|
|
|
export const HEALTH_P0_READ_COLUMNS = Object.freeze([
|
|
'id',
|
|
...HEALTH_P0_INSERT_COLUMNS,
|
|
'created_at',
|
|
]);
|
|
|
|
export const HEALTH_P0_CREATE_TABLE_SQL = `
|
|
CREATE TABLE IF NOT EXISTS health_p0_daily_log (
|
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
participant_code TEXT NOT NULL,
|
|
log_date DATE NOT NULL,
|
|
am_measured_at TEXT NULL,
|
|
am_systolic INT NULL,
|
|
am_diastolic INT NULL,
|
|
am_pulse INT NULL,
|
|
pm_measured_at TEXT NULL,
|
|
pm_systolic INT NULL,
|
|
pm_diastolic INT NULL,
|
|
pm_pulse INT NULL,
|
|
sleep_start TEXT NULL,
|
|
sleep_end TEXT NULL,
|
|
sleep_minutes INT NULL,
|
|
sleep_source TEXT NULL,
|
|
weight_kg NUMERIC(5,1) NULL,
|
|
symptoms JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
alcohol TEXT NULL,
|
|
stress TEXT NULL,
|
|
medication_change TEXT NULL,
|
|
medication_note TEXT NULL,
|
|
other_note TEXT NULL,
|
|
entry_channel TEXT NULL,
|
|
photo_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMPTZ NULL
|
|
);
|
|
`.trim();
|
|
|
|
export function buildHealthP0DatasetRegistration() {
|
|
return {
|
|
name: HEALTH_P0_DATASET_NAME,
|
|
table: HEALTH_P0_DATASET_NAME,
|
|
description: 'MeMind Health P0 前置实验每日记录',
|
|
actions: ['read', 'insert'],
|
|
columns: {
|
|
read: [...HEALTH_P0_READ_COLUMNS],
|
|
insert: [...HEALTH_P0_INSERT_COLUMNS],
|
|
},
|
|
};
|
|
}
|
|
|
|
export function buildHealthP0BindSpecs({ password } = {}) {
|
|
return {
|
|
participant: {
|
|
relativePath: 'public/health-p0-log.html',
|
|
accessMode: 'public',
|
|
datasets: {
|
|
[HEALTH_P0_DATASET_NAME]: { insert: true, read: false },
|
|
},
|
|
},
|
|
researcher: {
|
|
relativePath: 'public/health-p0-admin.html',
|
|
accessMode: 'password',
|
|
password,
|
|
datasets: {
|
|
[HEALTH_P0_DATASET_NAME]: { insert: false, read: true },
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function assertHealthP0HtmlContract(html, { role } = {}) {
|
|
const source = String(html ?? '');
|
|
const issues = [];
|
|
if (!source.includes('/assets/page-data-client.js')) issues.push('missing_page_data_client');
|
|
if (/\bon(?:click|input|submit|change)\s*=/i.test(source)) issues.push('inline_event_handler');
|
|
if (/localStorage|sessionStorage|IndexedDB/i.test(source)) issues.push('browser_storage');
|
|
if (/127\.0\.0\.1:\d+/.test(source)) issues.push('hardcoded_localhost');
|
|
if (role === 'participant') {
|
|
if (!source.includes(`insertRow('${HEALTH_P0_DATASET_NAME}'`)
|
|
&& !source.includes(`insertRow("${HEALTH_P0_DATASET_NAME}"`)) {
|
|
issues.push('missing_insert');
|
|
}
|
|
if (source.includes('listRows(')) issues.push('participant_must_not_read');
|
|
}
|
|
if (role === 'researcher') {
|
|
if (!source.includes('authenticate(')) issues.push('missing_authenticate');
|
|
if (!source.includes('listRows(')) issues.push('missing_list_rows');
|
|
}
|
|
return { ok: issues.length === 0, issues };
|
|
}
|