Refactor exam information workflow

This commit is contained in:
2026-07-20 10:07:14 +08:00 Unverified
parent d4e085b6c5
commit 7094e9b916
22 changed files with 3233 additions and 2793 deletions
+288
View File
@@ -0,0 +1,288 @@
export function createSqliteAdapter(context) {
const {
mkdir,
dirname,
sqliteSchema,
mysqlSchema,
optional,
buildSeedOperations,
stateFromRows,
readSqliteRows,
readMysqlRows,
createRepository
} = context;
async function createSqliteStore({ path, seed }) {
const { DatabaseSync } = await import('node:sqlite');
await mkdir(dirname(path), { recursive: true });
const connection = new DatabaseSync(path, { timeout: 5000 });
connection.exec('PRAGMA journal_mode = WAL;');
connection.exec('PRAGMA synchronous = NORMAL;');
const tableExists = name => Boolean(connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
const ensureColumns = (table, columns) => {
if (!tableExists(table)) return;
const existing = new Set(connection.prepare(`PRAGMA table_info(${table})`).all().map(item => item.name));
for (const [name, definition] of columns) {
if (!existing.has(name)) connection.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
}
};
ensureColumns('users', [
['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'],
['candidate_number', 'TEXT'], ['must_change_password', 'INTEGER NOT NULL DEFAULT 0']
]);
ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]);
ensureColumns('candidate_profiles', [
['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'],
['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0']
]);
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
ensureColumns('test_centers', [
['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'],
['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT']
]);
ensureColumns('test_rooms', [['seat_plan', 'TEXT']]);
ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]);
if (tableExists('workflow_definitions')) {
const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || '';
if (!definitionSql.includes('candidate_account_batch')) {
connection.exec(`
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
CREATE TABLE workflow_definitions_v5 (
id TEXT PRIMARY KEY,
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')),
name TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
updated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
updated_at TEXT NOT NULL,
UNIQUE (business_type, active)
) STRICT;
INSERT INTO workflow_definitions_v5 (id, business_type, name, active, updated_by, updated_at)
SELECT id, business_type, name, active, updated_by, updated_at FROM workflow_definitions;
DROP TABLE workflow_definitions;
ALTER TABLE workflow_definitions_v5 RENAME TO workflow_definitions;
COMMIT;
PRAGMA foreign_keys = ON;
`);
}
}
if (tableExists('registrations')) {
const registrationsSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registrations'").get()?.sql || '';
if (/registration_number\s+TEXT\s+UNIQUE/i.test(registrationsSql)) {
connection.exec(`
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
CREATE TABLE registrations_v4 (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')),
created_at TEXT NOT NULL,
reviewed_at TEXT,
review_note TEXT,
registration_number TEXT,
number_rule_id TEXT,
UNIQUE (user_id, exam_id)
) STRICT;
INSERT INTO registrations_v4 (
id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id
) SELECT id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id FROM registrations;
DROP TABLE registrations;
ALTER TABLE registrations_v4 RENAME TO registrations;
COMMIT;
PRAGMA foreign_keys = ON;
`);
}
}
connection.exec(sqliteSchema);
connection.exec('CREATE UNIQUE INDEX IF NOT EXISTS uq_users_candidate_number ON users(candidate_number)');
const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get();
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
const extension = seed();
connection.exec('BEGIN IMMEDIATE');
try {
for (const school of extension.schools) connection.prepare(
'INSERT OR IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)'
).run(school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1);
for (const schoolClass of extension.classes) connection.prepare(
'INSERT OR IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)'
).run(schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1);
connection.prepare("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, 1) WHERE role = 'admin'").run();
for (const user of extension.users.filter(item => item.role === 'admin')) connection.prepare(
`INSERT OR IGNORE INTO users (
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`
).run(user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt);
for (const profile of extension.candidateProfiles) connection.prepare(
`UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?)
WHERE school = ? AND grade = ?`
).run(optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade);
if (!connection.prepare('SELECT id FROM test_centers LIMIT 1').get()) {
for (const center of extension.testCenters) connection.prepare(
'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt);
}
if (!connection.prepare('SELECT id FROM number_rules LIMIT 1').get()) {
for (const rule of extension.numberRules) {
connection.prepare('INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt);
rule.segments.forEach((segment, index) => connection.prepare(
'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)'
).run(segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0)));
}
}
if (!connection.prepare('SELECT id FROM workflow_definitions LIMIT 1').get()) {
for (const workflow of extension.workflows) {
connection.prepare(
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt);
workflow.steps.forEach((step, index) => connection.prepare(
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)'
).run(step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel));
}
}
connection.prepare('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1').run();
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
}
}
if (existingSystem && Number(existingSystem.app_version || 1) < 3) {
const extension = seed();
connection.exec('BEGIN IMMEDIATE');
try {
for (const center of extension.testCenters) connection.prepare(
`UPDATE test_centers SET
code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?),
manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?),
gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?),
status = COALESCE(status, 'active'), notes = COALESCE(notes, ?)
WHERE id = ?`
).run(center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone),
optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id);
connection.prepare("UPDATE test_centers SET code = 'CENTER-' || substr(id, -8) WHERE code IS NULL OR code = ''").run();
if (!connection.prepare('SELECT id FROM test_rooms LIMIT 1').get()) {
for (const room of extension.testRooms) connection.prepare(
`INSERT INTO test_rooms (
id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity),
Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes));
}
const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change');
if (centerWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1").get()) {
connection.prepare(
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt);
for (const [index, step] of centerWorkflow.steps.entries()) connection.prepare(
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)'
).run(step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel);
}
connection.prepare('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1').run();
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
}
}
if (existingSystem && Number(existingSystem.app_version || 1) < 4) {
const extension = seed();
connection.exec('BEGIN IMMEDIATE');
try {
for (const user of extension.users.filter(item => item.role === 'candidate')) connection.prepare(
`UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?),
must_change_password = COALESCE(must_change_password, ?) WHERE id = ?`
).run(optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id);
connection.prepare(`UPDATE users SET candidate_number = COALESCE(
(SELECT registration_number FROM registrations WHERE registrations.user_id = users.id AND registration_number IS NOT NULL AND registration_number <> '' ORDER BY created_at LIMIT 1),
'CAND-' || substr(id, -10)
) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`).run();
for (const profile of extension.candidateProfiles) connection.prepare(
`UPDATE candidate_profiles SET native_place = COALESCE(native_place, ?), birth_date = COALESCE(birth_date, ?),
ethnicity = COALESCE(ethnicity, ?), postal_code = COALESCE(postal_code, ?), guardian_name = COALESCE(guardian_name, ?),
guardian_phone = COALESCE(guardian_phone, ?), profile_completed = ? WHERE id = ?`
).run(optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode),
optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id);
connection.prepare(`UPDATE registrations SET registration_number = (
SELECT candidate_number FROM users WHERE users.id = registrations.user_id
) WHERE registration_number IS NULL OR registration_number = ''`).run();
connection.prepare('UPDATE schema_metadata SET schema_version = 4, app_version = 4 WHERE id = 1').run();
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
}
}
if (existingSystem && Number(existingSystem.app_version || 1) < 5) {
const extension = seed();
const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch');
connection.exec('BEGIN IMMEDIATE');
try {
if (batchWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1").get()) {
connection.prepare(
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt);
for (const [index, step] of batchWorkflow.steps.entries()) connection.prepare(
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)'
).run(step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel);
}
connection.prepare('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1').run();
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
}
}
if (existingSystem && Number(existingSystem.app_version || 1) < 6) {
connection.prepare('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1').run();
}
if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
const initialState = seed();
connection.exec('BEGIN IMMEDIATE');
try {
connection.prepare(`
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 6, ?, ?, ?)
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
}
}
const transaction = async operations => {
connection.exec('BEGIN IMMEDIATE');
try {
for (const item of operations) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
throw error;
}
};
return createRepository({
client: 'sqlite',
location: path,
read: async () => stateFromRows(readSqliteRows(connection)),
transaction,
close: async () => connection.close()
});
}
return createSqliteStore;
}