Refactor exam information workflow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user