import { mkdir } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; export const relationalTables = [ 'schema_metadata', 'organization', 'schools', 'school_classes', 'users', 'candidate_profiles', 'notices', 'exams', 'exam_subjects', 'registrations', 'registration_subjects', 'admit_cards', 'results', 'test_centers', 'test_rooms', 'center_change_requests', 'center_change_rooms', 'number_rules', 'number_rule_segments', 'candidate_account_batches', 'candidate_account_batch_items', 'workflow_definitions', 'workflow_steps', 'workflow_instances', 'workflow_actions', 'audit_logs' ]; const sqliteSchema = ` PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS schema_metadata ( id INTEGER PRIMARY KEY CHECK (id = 1), schema_version INTEGER NOT NULL DEFAULT 1, app_version INTEGER NOT NULL DEFAULT 1, self_registration_enabled INTEGER NOT NULL DEFAULT 0 CHECK (self_registration_enabled IN (0, 1)), created_at TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS organization ( id INTEGER PRIMARY KEY CHECK (id = 1), name TEXT NOT NULL, code TEXT NOT NULL, phone TEXT NOT NULL, address TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS schools ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, code TEXT NOT NULL UNIQUE, address TEXT, active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)) ) STRICT; CREATE TABLE IF NOT EXISTS school_classes ( id TEXT PRIMARY KEY, school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, name TEXT NOT NULL, grade TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), UNIQUE (school_id, name) ) STRICT; CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, candidate_number TEXT UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')), admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')), school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL, active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)), display_name TEXT NOT NULL, created_at TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS candidate_profiles ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, gender TEXT, id_number TEXT NOT NULL UNIQUE, phone TEXT NOT NULL, email TEXT, school TEXT, grade TEXT, school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL, address TEXT, emergency_contact TEXT, emergency_phone 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 CHECK (profile_completed IN (0, 1)), status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), review_note TEXT, reviewed_at TEXT, reviewer_id TEXT REFERENCES users(id) ON DELETE SET NULL, updated_at TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS notices ( id TEXT PRIMARY KEY, title TEXT NOT NULL, summary TEXT NOT NULL, content TEXT NOT NULL, category TEXT NOT NULL, pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)), status TEXT NOT NULL CHECK (status IN ('draft', 'published')), publish_at TEXT, created_at TEXT, author TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS exams ( id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, description TEXT NOT NULL, registration_start TEXT NOT NULL, registration_end TEXT NOT NULL, exam_start TEXT NOT NULL, exam_end TEXT NOT NULL, admit_download_start TEXT NOT NULL, admit_download_end TEXT NOT NULL, location TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')), created_at TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS exam_subjects ( id TEXT PRIMARY KEY, exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, name TEXT NOT NULL, subject_date TEXT NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, fee REAL NOT NULL DEFAULT 0, position INTEGER NOT NULL, UNIQUE (exam_id, position) ) STRICT; CREATE TABLE IF NOT EXISTS registrations ( 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; CREATE TABLE IF NOT EXISTS registration_subjects ( registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, PRIMARY KEY (registration_id, subject_id) ) STRICT; CREATE TABLE IF NOT EXISTS admit_cards ( registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE, card_number TEXT NOT NULL UNIQUE, test_center TEXT NOT NULL, room TEXT NOT NULL, seat TEXT NOT NULL, generated_at TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS results ( id TEXT PRIMARY KEY, registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, score REAL NOT NULL CHECK (score >= 0 AND score <= 150), grade TEXT NOT NULL, published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)), updated_at TEXT, published_at TEXT, UNIQUE (registration_id, subject_id) ) STRICT; CREATE TABLE IF NOT EXISTS test_centers ( id TEXT PRIMARY KEY, school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, address TEXT NOT NULL, contact TEXT, manager_name TEXT, manager_phone TEXT, emergency_phone TEXT, gate_open_time TEXT, transport TEXT, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), notes TEXT, rooms TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (school_id, name) ) STRICT; CREATE TABLE IF NOT EXISTS test_rooms ( id TEXT PRIMARY KEY, center_id TEXT NOT NULL REFERENCES test_centers(id) ON DELETE CASCADE, code TEXT NOT NULL, name TEXT NOT NULL, building TEXT NOT NULL, floor TEXT, capacity INTEGER NOT NULL CHECK (capacity > 0), seat_start INTEGER NOT NULL DEFAULT 1 CHECK (seat_start > 0), seat_end INTEGER NOT NULL CHECK (seat_end >= seat_start), room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), notes TEXT, UNIQUE (center_id, code) ) STRICT; CREATE TABLE IF NOT EXISTS center_change_requests ( id TEXT PRIMARY KEY, center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL, school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, request_type TEXT NOT NULL CHECK (request_type IN ('create', 'update')), code TEXT NOT NULL, name TEXT NOT NULL, address TEXT NOT NULL, contact TEXT, manager_name TEXT, manager_phone TEXT, emergency_phone TEXT, gate_open_time TEXT, transport TEXT, center_status TEXT NOT NULL CHECK (center_status IN ('active', 'inactive')), notes TEXT, status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), review_note TEXT, requested_by TEXT REFERENCES users(id) ON DELETE SET NULL, created_at TEXT NOT NULL, reviewed_at TEXT ) STRICT; CREATE TABLE IF NOT EXISTS center_change_rooms ( id TEXT PRIMARY KEY, request_id TEXT NOT NULL REFERENCES center_change_requests(id) ON DELETE CASCADE, room_id TEXT, code TEXT NOT NULL, name TEXT NOT NULL, building TEXT NOT NULL, floor TEXT, capacity INTEGER NOT NULL CHECK (capacity > 0), seat_start INTEGER NOT NULL DEFAULT 1, seat_end INTEGER NOT NULL, room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')), status TEXT NOT NULL CHECK (status IN ('active', 'inactive')), notes TEXT, UNIQUE (request_id, code) ) STRICT; CREATE TABLE IF NOT EXISTS number_rules ( id TEXT PRIMARY KEY, name TEXT NOT NULL, separator TEXT NOT NULL DEFAULT '', active INTEGER NOT NULL DEFAULT 0 CHECK (active IN (0, 1)), created_by TEXT REFERENCES users(id) ON DELETE SET NULL, updated_at TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS number_rule_segments ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES number_rules(id) ON DELETE CASCADE, position INTEGER NOT NULL, type TEXT NOT NULL CHECK (type IN ('year', 'school_code', 'gender', 'sequence', 'literal')), value TEXT, width INTEGER NOT NULL DEFAULT 0, UNIQUE (rule_id, position) ) STRICT; CREATE TABLE IF NOT EXISTS candidate_account_batches ( id TEXT PRIMARY KEY, school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, requested_by TEXT REFERENCES users(id) ON DELETE SET NULL, status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), review_note TEXT, created_at TEXT NOT NULL, reviewed_at TEXT ) STRICT; CREATE TABLE IF NOT EXISTS candidate_account_batch_items ( id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES candidate_account_batches(id) ON DELETE CASCADE, class_id TEXT NOT NULL REFERENCES school_classes(id) ON DELETE RESTRICT, position INTEGER NOT NULL, candidate_number TEXT UNIQUE, initial_password TEXT, user_id TEXT UNIQUE REFERENCES users(id) ON DELETE SET NULL, created_at TEXT, UNIQUE (batch_id, position) ) STRICT; CREATE TABLE IF NOT EXISTS workflow_definitions ( 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; CREATE TABLE IF NOT EXISTS workflow_steps ( id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE CASCADE, position INTEGER NOT NULL, name TEXT NOT NULL, admin_level TEXT NOT NULL CHECK (admin_level IN ('school', 'super')), UNIQUE (workflow_id, position) ) STRICT; CREATE TABLE IF NOT EXISTS workflow_instances ( id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE RESTRICT, business_type TEXT NOT NULL, business_id TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), current_step INTEGER NOT NULL DEFAULT 1, assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, created_at TEXT NOT NULL, completed_at TEXT ) STRICT; CREATE TABLE IF NOT EXISTS workflow_actions ( id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE, actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, action TEXT NOT NULL CHECK (action IN ('submit', 'approve', 'reject', 'transfer', 'return', 'supervise')), note TEXT, from_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, to_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, created_at TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS audit_logs ( id TEXT PRIMARY KEY, actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, action TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL ) STRICT; CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status); CREATE INDEX IF NOT EXISTS idx_profiles_scope ON candidate_profiles(school_id, class_id, status); CREATE INDEX IF NOT EXISTS idx_notices_status_publish ON notices(status, publish_at); CREATE INDEX IF NOT EXISTS idx_exams_status_registration ON exams(status, registration_start, registration_end); CREATE INDEX IF NOT EXISTS idx_subjects_exam ON exam_subjects(exam_id, position); CREATE INDEX IF NOT EXISTS idx_registrations_status ON registrations(status); CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id); CREATE INDEX IF NOT EXISTS idx_workflow_inbox ON workflow_instances(status, assignee_id, business_type); CREATE UNIQUE INDEX IF NOT EXISTS uq_test_centers_code ON test_centers(code); CREATE INDEX IF NOT EXISTS idx_rooms_center ON test_rooms(center_id, status, code); CREATE INDEX IF NOT EXISTS idx_center_changes_school ON center_change_requests(school_id, status, created_at); CREATE INDEX IF NOT EXISTS idx_account_batches_school ON candidate_account_batches(school_id, status, created_at); CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position); CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); `; const mysqlSchema = [ `CREATE TABLE IF NOT EXISTS schema_metadata ( id TINYINT UNSIGNED NOT NULL, schema_version INT UNSIGNED NOT NULL DEFAULT 1, app_version INT UNSIGNED NOT NULL DEFAULT 1, self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE, created_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), CONSTRAINT chk_schema_metadata_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS organization ( id TINYINT UNSIGNED NOT NULL, name VARCHAR(120) NOT NULL, code VARCHAR(60) NOT NULL, phone VARCHAR(60) NOT NULL, address VARCHAR(255) NOT NULL, PRIMARY KEY (id), CONSTRAINT chk_organization_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS schools ( id VARCHAR(64) NOT NULL, name VARCHAR(160) NOT NULL, code VARCHAR(40) NOT NULL, address VARCHAR(255) NULL, active BOOLEAN NOT NULL DEFAULT TRUE, PRIMARY KEY (id), UNIQUE KEY uq_schools_name (name), UNIQUE KEY uq_schools_code (code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS school_classes ( id VARCHAR(64) NOT NULL, school_id VARCHAR(64) NOT NULL, name VARCHAR(100) NOT NULL, grade VARCHAR(60) NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE, PRIMARY KEY (id), UNIQUE KEY uq_classes_school_name (school_id, name), CONSTRAINT fk_classes_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS users ( id VARCHAR(64) NOT NULL, username VARCHAR(100) NOT NULL, candidate_number VARCHAR(120) NULL, password_hash VARCHAR(255) NOT NULL, role ENUM('admin', 'candidate') NOT NULL, admin_level ENUM('super', 'school', 'class') NULL, school_id VARCHAR(64) NULL, class_id VARCHAR(64) NULL, active BOOLEAN NOT NULL DEFAULT TRUE, must_change_password BOOLEAN NOT NULL DEFAULT FALSE, display_name VARCHAR(100) NOT NULL, created_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_users_username (username), UNIQUE KEY uq_users_candidate_number (candidate_number), KEY idx_users_admin_scope (role, admin_level, school_id, class_id), CONSTRAINT fk_users_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL, CONSTRAINT fk_users_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS candidate_profiles ( id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, name VARCHAR(100) NOT NULL, gender VARCHAR(20) NULL, id_number VARCHAR(60) NOT NULL, phone VARCHAR(60) NOT NULL, email VARCHAR(160) NULL, school VARCHAR(160) NULL, grade VARCHAR(100) NULL, school_id VARCHAR(64) NULL, class_id VARCHAR(64) NULL, address VARCHAR(255) NULL, emergency_contact VARCHAR(100) NULL, emergency_phone VARCHAR(60) NULL, native_place VARCHAR(160) NULL, birth_date VARCHAR(20) NULL, ethnicity VARCHAR(60) NULL, postal_code VARCHAR(20) NULL, guardian_name VARCHAR(100) NULL, guardian_phone VARCHAR(60) NULL, profile_completed BOOLEAN NOT NULL DEFAULT FALSE, status ENUM('pending', 'approved', 'rejected') NOT NULL, review_note VARCHAR(500) NULL, reviewed_at VARCHAR(35) NULL, reviewer_id VARCHAR(64) NULL, updated_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_profiles_user (user_id), UNIQUE KEY uq_profiles_id_number (id_number), KEY idx_profiles_status (status), CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_profiles_reviewer FOREIGN KEY (reviewer_id) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_profiles_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL, CONSTRAINT fk_profiles_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS notices ( id VARCHAR(64) NOT NULL, title VARCHAR(240) NOT NULL, summary VARCHAR(500) NOT NULL, content TEXT NOT NULL, category VARCHAR(60) NOT NULL, pinned BOOLEAN NOT NULL DEFAULT FALSE, status ENUM('draft', 'published') NOT NULL, publish_at VARCHAR(35) NULL, created_at VARCHAR(35) NULL, author VARCHAR(100) NOT NULL, PRIMARY KEY (id), KEY idx_notices_status_publish (status, publish_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS exams ( id VARCHAR(64) NOT NULL, code VARCHAR(60) NOT NULL, name VARCHAR(200) NOT NULL, description TEXT NOT NULL, registration_start VARCHAR(35) NOT NULL, registration_end VARCHAR(35) NOT NULL, exam_start VARCHAR(35) NOT NULL, exam_end VARCHAR(35) NOT NULL, admit_download_start VARCHAR(35) NOT NULL, admit_download_end VARCHAR(35) NOT NULL, location VARCHAR(200) NOT NULL, status ENUM('draft', 'published', 'closed') NOT NULL, created_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_exams_code (code), KEY idx_exams_status_registration (status, registration_start, registration_end) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS exam_subjects ( id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, name VARCHAR(100) NOT NULL, subject_date VARCHAR(35) NOT NULL, start_time VARCHAR(20) NOT NULL, end_time VARCHAR(20) NOT NULL, fee DOUBLE NOT NULL DEFAULT 0, position INT UNSIGNED NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_subjects_exam_position (exam_id, position), KEY idx_subjects_exam (exam_id, position), CONSTRAINT fk_subjects_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS registrations ( id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, status ENUM('pending', 'approved', 'rejected') NOT NULL, payment_status ENUM('unpaid', 'paid', 'refunded') NOT NULL, created_at VARCHAR(35) NOT NULL, reviewed_at VARCHAR(35) NULL, review_note VARCHAR(500) NULL, registration_number VARCHAR(120) NULL, number_rule_id VARCHAR(64) NULL, PRIMARY KEY (id), UNIQUE KEY uq_registrations_user_exam (user_id, exam_id), KEY idx_registrations_status (status), KEY idx_registrations_exam (exam_id), CONSTRAINT fk_registrations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_registrations_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS registration_subjects ( registration_id VARCHAR(64) NOT NULL, subject_id VARCHAR(64) NOT NULL, PRIMARY KEY (registration_id, subject_id), CONSTRAINT fk_registration_subjects_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, CONSTRAINT fk_registration_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS admit_cards ( registration_id VARCHAR(64) NOT NULL, card_number VARCHAR(100) NOT NULL, test_center VARCHAR(200) NOT NULL, room VARCHAR(100) NOT NULL, seat VARCHAR(30) NOT NULL, generated_at VARCHAR(35) NOT NULL, PRIMARY KEY (registration_id), UNIQUE KEY uq_admit_cards_number (card_number), CONSTRAINT fk_admit_cards_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS results ( id VARCHAR(64) NOT NULL, registration_id VARCHAR(64) NOT NULL, subject_id VARCHAR(64) NOT NULL, score DOUBLE NOT NULL, grade VARCHAR(20) NOT NULL, published BOOLEAN NOT NULL DEFAULT FALSE, updated_at VARCHAR(35) NULL, published_at VARCHAR(35) NULL, PRIMARY KEY (id), UNIQUE KEY uq_results_registration_subject (registration_id, subject_id), KEY idx_results_registration (registration_id, published), CONSTRAINT chk_results_score CHECK (score >= 0 AND score <= 150), CONSTRAINT fk_results_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, CONSTRAINT fk_results_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS test_centers ( id VARCHAR(64) NOT NULL, school_id VARCHAR(64) NOT NULL, code VARCHAR(40) NOT NULL, name VARCHAR(200) NOT NULL, address VARCHAR(255) NOT NULL, contact VARCHAR(100) NULL, manager_name VARCHAR(100) NULL, manager_phone VARCHAR(60) NULL, emergency_phone VARCHAR(60) NULL, gate_open_time VARCHAR(40) NULL, transport VARCHAR(500) NULL, status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', notes VARCHAR(1000) NULL, rooms TEXT NOT NULL, updated_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_centers_code (code), UNIQUE KEY uq_centers_school_name (school_id, name), CONSTRAINT fk_centers_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS test_rooms ( id VARCHAR(64) NOT NULL, center_id VARCHAR(64) NOT NULL, code VARCHAR(40) NOT NULL, name VARCHAR(120) NOT NULL, building VARCHAR(120) NOT NULL, floor VARCHAR(40) NULL, capacity INT UNSIGNED NOT NULL, seat_start INT UNSIGNED NOT NULL DEFAULT 1, seat_end INT UNSIGNED NOT NULL, room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL, status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', notes VARCHAR(500) NULL, PRIMARY KEY (id), UNIQUE KEY uq_rooms_center_code (center_id, code), KEY idx_rooms_center (center_id, status, code), CONSTRAINT fk_rooms_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS center_change_requests ( id VARCHAR(64) NOT NULL, center_id VARCHAR(64) NULL, school_id VARCHAR(64) NOT NULL, request_type ENUM('create', 'update') NOT NULL, code VARCHAR(40) NOT NULL, name VARCHAR(200) NOT NULL, address VARCHAR(255) NOT NULL, contact VARCHAR(100) NULL, manager_name VARCHAR(100) NULL, manager_phone VARCHAR(60) NULL, emergency_phone VARCHAR(60) NULL, gate_open_time VARCHAR(40) NULL, transport VARCHAR(500) NULL, center_status ENUM('active', 'inactive') NOT NULL, notes VARCHAR(1000) NULL, status ENUM('pending', 'approved', 'rejected') NOT NULL, review_note VARCHAR(500) NULL, requested_by VARCHAR(64) NULL, created_at VARCHAR(35) NOT NULL, reviewed_at VARCHAR(35) NULL, PRIMARY KEY (id), KEY idx_center_changes_school (school_id, status, created_at), CONSTRAINT fk_center_change_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE SET NULL, CONSTRAINT fk_center_change_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE, CONSTRAINT fk_center_change_requester FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS center_change_rooms ( id VARCHAR(64) NOT NULL, request_id VARCHAR(64) NOT NULL, room_id VARCHAR(64) NULL, code VARCHAR(40) NOT NULL, name VARCHAR(120) NOT NULL, building VARCHAR(120) NOT NULL, floor VARCHAR(40) NULL, capacity INT UNSIGNED NOT NULL, seat_start INT UNSIGNED NOT NULL DEFAULT 1, seat_end INT UNSIGNED NOT NULL, room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL, status ENUM('active', 'inactive') NOT NULL, notes VARCHAR(500) NULL, PRIMARY KEY (id), UNIQUE KEY uq_center_change_rooms_code (request_id, code), CONSTRAINT fk_center_change_rooms_request FOREIGN KEY (request_id) REFERENCES center_change_requests(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS number_rules ( id VARCHAR(64) NOT NULL, name VARCHAR(120) NOT NULL, separator VARCHAR(10) NOT NULL DEFAULT '', active BOOLEAN NOT NULL DEFAULT FALSE, created_by VARCHAR(64) NULL, updated_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), CONSTRAINT fk_number_rules_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS number_rule_segments ( id VARCHAR(64) NOT NULL, rule_id VARCHAR(64) NOT NULL, position INT UNSIGNED NOT NULL, type ENUM('year', 'school_code', 'gender', 'sequence', 'literal') NOT NULL, value VARCHAR(60) NULL, width INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uq_rule_segments_position (rule_id, position), CONSTRAINT fk_rule_segments_rule FOREIGN KEY (rule_id) REFERENCES number_rules(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS candidate_account_batches ( id VARCHAR(64) NOT NULL, school_id VARCHAR(64) NOT NULL, requested_by VARCHAR(64) NULL, status ENUM('pending', 'approved', 'rejected') NOT NULL, review_note VARCHAR(500) NULL, created_at VARCHAR(35) NOT NULL, reviewed_at VARCHAR(35) NULL, PRIMARY KEY (id), KEY idx_account_batches_school (school_id, status, created_at), CONSTRAINT fk_account_batches_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE, CONSTRAINT fk_account_batches_requester FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS candidate_account_batch_items ( id VARCHAR(64) NOT NULL, batch_id VARCHAR(64) NOT NULL, class_id VARCHAR(64) NOT NULL, position INT UNSIGNED NOT NULL, candidate_number VARCHAR(120) NULL, initial_password VARCHAR(120) NULL, user_id VARCHAR(64) NULL, created_at VARCHAR(35) NULL, PRIMARY KEY (id), UNIQUE KEY uq_account_batch_position (batch_id, position), UNIQUE KEY uq_account_batch_number (candidate_number), UNIQUE KEY uq_account_batch_user (user_id), KEY idx_account_batch_items (batch_id, class_id, position), CONSTRAINT fk_account_batch_items_batch FOREIGN KEY (batch_id) REFERENCES candidate_account_batches(id) ON DELETE CASCADE, CONSTRAINT fk_account_batch_items_class FOREIGN KEY (class_id) REFERENCES school_classes(id), CONSTRAINT fk_account_batch_items_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS workflow_definitions ( id VARCHAR(64) NOT NULL, business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL, name VARCHAR(120) NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE, updated_by VARCHAR(64) NULL, updated_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_workflow_type_active (business_type, active), CONSTRAINT fk_workflow_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS workflow_steps ( id VARCHAR(64) NOT NULL, workflow_id VARCHAR(64) NOT NULL, position INT UNSIGNED NOT NULL, name VARCHAR(120) NOT NULL, admin_level ENUM('school', 'super') NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_workflow_steps_position (workflow_id, position), CONSTRAINT fk_workflow_steps_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS workflow_instances ( id VARCHAR(64) NOT NULL, workflow_id VARCHAR(64) NOT NULL, business_type VARCHAR(40) NOT NULL, business_id VARCHAR(64) NOT NULL, status ENUM('pending', 'approved', 'rejected') NOT NULL, current_step INT UNSIGNED NOT NULL DEFAULT 1, assignee_id VARCHAR(64) NULL, created_at VARCHAR(35) NOT NULL, completed_at VARCHAR(35) NULL, PRIMARY KEY (id), KEY idx_workflow_inbox (status, assignee_id, business_type), CONSTRAINT fk_workflow_instance_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id), CONSTRAINT fk_workflow_instance_assignee FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS workflow_actions ( id VARCHAR(64) NOT NULL, instance_id VARCHAR(64) NOT NULL, actor_id VARCHAR(64) NULL, action ENUM('submit', 'approve', 'reject', 'transfer', 'return', 'supervise') NOT NULL, note VARCHAR(500) NULL, from_assignee_id VARCHAR(64) NULL, to_assignee_id VARCHAR(64) NULL, created_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), KEY idx_workflow_actions_instance (instance_id, created_at), CONSTRAINT fk_workflow_action_instance FOREIGN KEY (instance_id) REFERENCES workflow_instances(id) ON DELETE CASCADE, CONSTRAINT fk_workflow_action_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_workflow_action_from FOREIGN KEY (from_assignee_id) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_workflow_action_to FOREIGN KEY (to_assignee_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS audit_logs ( id VARCHAR(64) NOT NULL, actor_id VARCHAR(64) NULL, action VARCHAR(100) NOT NULL, detail VARCHAR(1000) NOT NULL, created_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), KEY idx_audit_created (created_at), CONSTRAINT fk_audit_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci` ]; function validateState(state, source = '数据库') { const collections = [ 'schools', 'classes', 'users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results', 'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms', 'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems', 'workflows', 'workflowInstances', 'workflowActions', 'auditLogs' ]; if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) { throw new Error(`${source}中的应用数据格式无效`); } return state; } function buildSeedOperations(state) { validateState(state); const operations = []; const add = (sql, ...params) => operations.push({ sql, params }); const nullable = value => value == null || value === '' ? null : value; add( 'UPDATE schema_metadata SET schema_version = 5, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0, state.meta?.createdAt || new Date().toISOString() ); add( 'INSERT INTO organization (id, name, code, phone, address) VALUES (1, ?, ?, ?, ?)', state.organization?.name || '', state.organization?.code || '', state.organization?.phone || '', state.organization?.address || '' ); for (const school of state.schools) { add( 'INSERT INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)', school.id, school.name, school.code, nullable(school.address), school.active === false ? 0 : 1 ); } for (const schoolClass of state.classes) { add( 'INSERT INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)', schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1 ); } for (const user of state.users) { add( `INSERT INTO users ( id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, must_change_password, display_name, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, user.id, user.username, nullable(user.candidateNumber), user.passwordHash, user.role, nullable(user.adminLevel), nullable(user.schoolId), nullable(user.classId), user.active === false ? 0 : 1, user.mustChangePassword ? 1 : 0, user.displayName, user.createdAt ); } for (const profile of state.candidateProfiles) { add( `INSERT INTO candidate_profiles ( id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address, emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name, guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, profile.id, profile.userId, profile.name, nullable(profile.gender), profile.idNumber, profile.phone, nullable(profile.email), nullable(profile.school), nullable(profile.grade), nullable(profile.schoolId), nullable(profile.classId), nullable(profile.address), nullable(profile.emergencyContact), nullable(profile.emergencyPhone), nullable(profile.nativePlace), nullable(profile.birthDate), nullable(profile.ethnicity), nullable(profile.postalCode), nullable(profile.guardianName), nullable(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.status, nullable(profile.reviewNote), nullable(profile.reviewedAt), nullable(profile.reviewerId), profile.updatedAt ); } for (const notice of state.notices) { add( `INSERT INTO notices ( id, title, summary, content, category, pinned, status, publish_at, created_at, author ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, notice.id, notice.title, notice.summary, notice.content, notice.category, notice.pinned ? 1 : 0, notice.status, nullable(notice.publishAt), nullable(notice.createdAt), notice.author ); } for (const exam of state.exams) { add( `INSERT INTO exams ( id, code, name, description, registration_start, registration_end, exam_start, exam_end, admit_download_start, admit_download_end, location, status, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd, exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '', exam.status, exam.createdAt ); exam.subjects.forEach((subject, index) => add( `INSERT INTO exam_subjects ( id, exam_id, name, subject_date, start_time, end_time, fee, position ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.order || index + 1) )); } for (const registration of state.registrations) { add( `INSERT INTO registrations ( id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, registration.id, registration.userId, registration.examId, registration.status, registration.paymentStatus, registration.createdAt, nullable(registration.reviewedAt), nullable(registration.reviewNote), nullable(registration.registrationNumber), nullable(registration.numberRuleId) ); for (const subjectId of registration.subjectIds) { add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId); } if (registration.admitCard) { add( `INSERT INTO admit_cards ( registration_id, card_number, test_center, room, seat, generated_at ) VALUES (?, ?, ?, ?, ?, ?)`, registration.id, registration.admitCard.number, registration.admitCard.testCenter, registration.admitCard.room, registration.admitCard.seat, registration.admitCard.generatedAt ); } } for (const result of state.results) { add( `INSERT INTO results ( id, registration_id, subject_id, score, grade, published, updated_at, published_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, result.id, result.registrationId, result.subjectId, Number(result.score), result.grade, result.published ? 1 : 0, nullable(result.updatedAt), nullable(result.publishedAt) ); } for (const center of state.testCenters) { add( `INSERT INTO test_centers ( id, school_id, code, name, address, contact, manager_name, manager_phone, emergency_phone, gate_open_time, transport, status, notes, rooms, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, center.id, center.schoolId, center.code, center.name, center.address, nullable(center.contact), nullable(center.managerName), nullable(center.managerPhone), nullable(center.emergencyPhone), nullable(center.gateOpenTime), nullable(center.transport), center.status || 'active', nullable(center.notes), center.rooms || '', center.updatedAt ); } for (const room of state.testRooms) { add( `INSERT INTO test_rooms ( id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.id, room.centerId, room.code, room.name, room.building, nullable(room.floor), Number(room.capacity), Number(room.seatStart || 1), Number(room.seatEnd), room.roomType, room.status || 'active', nullable(room.notes) ); } for (const request of state.centerChangeRequests) { add( `INSERT INTO center_change_requests ( id, center_id, school_id, request_type, code, name, address, contact, manager_name, manager_phone, emergency_phone, gate_open_time, transport, center_status, notes, status, review_note, requested_by, created_at, reviewed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, request.id, nullable(request.centerId), request.schoolId, request.requestType, request.code, request.name, request.address, nullable(request.contact), nullable(request.managerName), nullable(request.managerPhone), nullable(request.emergencyPhone), nullable(request.gateOpenTime), nullable(request.transport), request.centerStatus || 'active', nullable(request.notes), request.status, nullable(request.reviewNote), nullable(request.requestedBy), request.createdAt, nullable(request.reviewedAt) ); } for (const room of state.centerChangeRooms) { add( `INSERT INTO center_change_rooms ( id, request_id, room_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.id, room.requestId, nullable(room.roomId), room.code, room.name, room.building, nullable(room.floor), Number(room.capacity), Number(room.seatStart || 1), Number(room.seatEnd), room.roomType, room.status || 'active', nullable(room.notes) ); } for (const rule of state.numberRules) { add( 'INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, nullable(rule.createdBy), rule.updatedAt ); rule.segments.forEach((segment, index) => add( 'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)', segment.id, rule.id, Number(segment.position || index + 1), segment.type, nullable(segment.value), Number(segment.width || 0) )); } for (const batch of state.candidateAccountBatches) { add( `INSERT INTO candidate_account_batches ( id, school_id, requested_by, status, review_note, created_at, reviewed_at ) VALUES (?, ?, ?, ?, ?, ?, ?)`, batch.id, batch.schoolId, nullable(batch.requestedBy), batch.status, nullable(batch.reviewNote), batch.createdAt, nullable(batch.reviewedAt) ); } for (const item of state.candidateAccountBatchItems) { add( `INSERT INTO candidate_account_batch_items ( id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, item.id, item.batchId, item.classId, Number(item.position), nullable(item.candidateNumber), nullable(item.initialPassword), nullable(item.userId), nullable(item.createdAt) ); } for (const workflow of state.workflows) { add( `INSERT INTO workflow_definitions ( id, business_type, name, active, updated_by, updated_at ) VALUES (?, ?, ?, ?, ?, ?)`, workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, nullable(workflow.updatedBy), workflow.updatedAt ); workflow.steps.forEach((step, index) => add( `INSERT INTO workflow_steps ( id, workflow_id, position, name, admin_level ) VALUES (?, ?, ?, ?, ?)`, step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel )); } for (const instance of state.workflowInstances) { add( `INSERT INTO workflow_instances ( id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, instance.id, instance.workflowId, instance.businessType, instance.businessId, instance.status, Number(instance.currentStep || 1), nullable(instance.assigneeId), instance.createdAt, nullable(instance.completedAt) ); } for (const action of state.workflowActions) { add( `INSERT INTO workflow_actions ( id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, action.id, action.instanceId, nullable(action.actorId), action.action, nullable(action.note), nullable(action.fromAssigneeId), nullable(action.toAssigneeId), action.createdAt ); } for (const log of state.auditLogs) { add( 'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)', log.id, nullable(log.actorId), log.action, log.detail, log.createdAt ); } return operations; } function stateFromRows(rows) { const subjectsByExam = new Map(); for (const row of rows.subjects) { const subject = { id: row.id, name: row.name, date: row.subject_date, start: row.start_time, end: row.end_time, fee: Number(row.fee), order: Number(row.position) }; const subjects = subjectsByExam.get(row.exam_id) || []; subjects.push(subject); subjectsByExam.set(row.exam_id, subjects); } const registrationSubjects = new Map(); for (const row of rows.registrationSubjects) { const subjectIds = registrationSubjects.get(row.registration_id) || []; subjectIds.push(row.subject_id); registrationSubjects.set(row.registration_id, subjectIds); } const admitCards = new Map(rows.admitCards.map(row => [row.registration_id, { number: row.card_number, testCenter: row.test_center, room: row.room, seat: row.seat, generatedAt: row.generated_at }])); const ruleSegments = new Map(); for (const row of rows.numberRuleSegments) { const segments = ruleSegments.get(row.rule_id) || []; segments.push({ id: row.id, type: row.type, value: row.value || '', width: Number(row.width || 0), position: Number(row.position) }); ruleSegments.set(row.rule_id, segments); } const workflowSteps = new Map(); for (const row of rows.workflowSteps) { const steps = workflowSteps.get(row.workflow_id) || []; steps.push({ id: row.id, name: row.name, adminLevel: row.admin_level, position: Number(row.position) }); workflowSteps.set(row.workflow_id, steps); } const organization = rows.organization; const state = { meta: { version: Number(rows.system.app_version), createdAt: rows.system.created_at }, settings: { selfRegistrationEnabled: Boolean(rows.system.self_registration_enabled) }, organization: { name: organization.name, code: organization.code, phone: organization.phone, address: organization.address }, schools: rows.schools.map(row => ({ id: row.id, name: row.name, code: row.code, address: row.address || '', active: Boolean(row.active) })), classes: rows.classes.map(row => ({ id: row.id, schoolId: row.school_id, name: row.name, grade: row.grade, active: Boolean(row.active) })), users: rows.users.map(row => ({ id: row.id, username: row.username, candidateNumber: row.candidate_number || '', passwordHash: row.password_hash, role: row.role, adminLevel: row.admin_level || (row.role === 'admin' ? 'super' : null), schoolId: row.school_id || null, classId: row.class_id || null, active: row.active == null ? true : Boolean(row.active), mustChangePassword: Boolean(row.must_change_password), displayName: row.display_name, createdAt: row.created_at })), candidateProfiles: rows.profiles.map(row => ({ id: row.id, userId: row.user_id, name: row.name, gender: row.gender || '', idNumber: row.id_number, phone: row.phone, email: row.email || '', school: row.school || '', grade: row.grade || '', schoolId: row.school_id || null, classId: row.class_id || null, address: row.address || '', emergencyContact: row.emergency_contact || '', emergencyPhone: row.emergency_phone || '', nativePlace: row.native_place || '', birthDate: row.birth_date || '', ethnicity: row.ethnicity || '', postalCode: row.postal_code || '', guardianName: row.guardian_name || '', guardianPhone: row.guardian_phone || '', profileCompleted: Boolean(row.profile_completed), status: row.status, reviewNote: row.review_note || '', reviewedAt: row.reviewed_at, reviewerId: row.reviewer_id, updatedAt: row.updated_at })), notices: rows.notices.map(row => ({ id: row.id, title: row.title, summary: row.summary, content: row.content, category: row.category, pinned: Boolean(row.pinned), status: row.status, publishAt: row.publish_at, createdAt: row.created_at, author: row.author })), exams: rows.exams.map(row => ({ id: row.id, code: row.code, name: row.name, description: row.description, registrationStart: row.registration_start, registrationEnd: row.registration_end, examStart: row.exam_start, examEnd: row.exam_end, admitDownloadStart: row.admit_download_start, admitDownloadEnd: row.admit_download_end, location: row.location, status: row.status, createdAt: row.created_at, subjects: subjectsByExam.get(row.id) || [] })), registrations: rows.registrations.map(row => ({ id: row.id, userId: row.user_id, examId: row.exam_id, subjectIds: registrationSubjects.get(row.id) || [], status: row.status, paymentStatus: row.payment_status, createdAt: row.created_at, reviewedAt: row.reviewed_at, reviewNote: row.review_note || '', registrationNumber: row.registration_number || '', numberRuleId: row.number_rule_id || null, admitCard: admitCards.get(row.id) || null })), results: rows.results.map(row => ({ id: row.id, registrationId: row.registration_id, subjectId: row.subject_id, score: Number(row.score), grade: row.grade, published: Boolean(row.published), updatedAt: row.updated_at, publishedAt: row.published_at })), testCenters: rows.testCenters.map(row => ({ id: row.id, schoolId: row.school_id, code: row.code || '', name: row.name, address: row.address, contact: row.contact || '', managerName: row.manager_name || '', managerPhone: row.manager_phone || '', emergencyPhone: row.emergency_phone || '', gateOpenTime: row.gate_open_time || '', transport: row.transport || '', status: row.status || 'active', notes: row.notes || '', rooms: row.rooms || '', updatedAt: row.updated_at })), testRooms: rows.testRooms.map(row => ({ id: row.id, centerId: row.center_id, code: row.code, name: row.name, building: row.building, floor: row.floor || '', capacity: Number(row.capacity), seatStart: Number(row.seat_start), seatEnd: Number(row.seat_end), roomType: row.room_type, status: row.status, notes: row.notes || '' })), centerChangeRequests: rows.centerChangeRequests.map(row => ({ id: row.id, centerId: row.center_id, schoolId: row.school_id, requestType: row.request_type, code: row.code, name: row.name, address: row.address, contact: row.contact || '', managerName: row.manager_name || '', managerPhone: row.manager_phone || '', emergencyPhone: row.emergency_phone || '', gateOpenTime: row.gate_open_time || '', transport: row.transport || '', centerStatus: row.center_status, notes: row.notes || '', status: row.status, reviewNote: row.review_note || '', requestedBy: row.requested_by, createdAt: row.created_at, reviewedAt: row.reviewed_at })), centerChangeRooms: rows.centerChangeRooms.map(row => ({ id: row.id, requestId: row.request_id, roomId: row.room_id, code: row.code, name: row.name, building: row.building, floor: row.floor || '', capacity: Number(row.capacity), seatStart: Number(row.seat_start), seatEnd: Number(row.seat_end), roomType: row.room_type, status: row.status, notes: row.notes || '' })), numberRules: rows.numberRules.map(row => ({ id: row.id, name: row.name, separator: row.separator, active: Boolean(row.active), createdBy: row.created_by, updatedAt: row.updated_at, segments: ruleSegments.get(row.id) || [] })), candidateAccountBatches: rows.candidateAccountBatches.map(row => ({ id: row.id, schoolId: row.school_id, requestedBy: row.requested_by, status: row.status, reviewNote: row.review_note || '', createdAt: row.created_at, reviewedAt: row.reviewed_at })), candidateAccountBatchItems: rows.candidateAccountBatchItems.map(row => ({ id: row.id, batchId: row.batch_id, classId: row.class_id, position: Number(row.position), candidateNumber: row.candidate_number || '', initialPassword: row.initial_password || '', userId: row.user_id, createdAt: row.created_at })), workflows: rows.workflows.map(row => ({ id: row.id, businessType: row.business_type, name: row.name, active: Boolean(row.active), updatedBy: row.updated_by, updatedAt: row.updated_at, steps: workflowSteps.get(row.id) || [] })), workflowInstances: rows.workflowInstances.map(row => ({ id: row.id, workflowId: row.workflow_id, businessType: row.business_type, businessId: row.business_id, status: row.status, currentStep: Number(row.current_step), assigneeId: row.assignee_id, createdAt: row.created_at, completedAt: row.completed_at })), workflowActions: rows.workflowActions.map(row => ({ id: row.id, instanceId: row.instance_id, actorId: row.actor_id, action: row.action, note: row.note || '', fromAssigneeId: row.from_assignee_id, toAssigneeId: row.to_assignee_id, createdAt: row.created_at })), auditLogs: rows.auditLogs.map(row => ({ id: row.id, actorId: row.actor_id, action: row.action, detail: row.detail, createdAt: row.created_at })) }; return state; } function readSqliteRows(connection) { return { system: connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get(), organization: connection.prepare('SELECT * FROM organization WHERE id = 1').get(), schools: connection.prepare('SELECT * FROM schools ORDER BY name, id').all(), classes: connection.prepare('SELECT * FROM school_classes ORDER BY school_id, grade, name, id').all(), users: connection.prepare('SELECT * FROM users ORDER BY created_at, id').all(), profiles: connection.prepare('SELECT * FROM candidate_profiles ORDER BY updated_at, id').all(), notices: connection.prepare('SELECT * FROM notices ORDER BY publish_at, created_at, id').all(), exams: connection.prepare('SELECT * FROM exams ORDER BY created_at, id').all(), subjects: connection.prepare('SELECT * FROM exam_subjects ORDER BY exam_id, position, id').all(), registrations: connection.prepare('SELECT * FROM registrations ORDER BY created_at, id').all(), registrationSubjects: connection.prepare('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id').all(), admitCards: connection.prepare('SELECT * FROM admit_cards ORDER BY registration_id').all(), results: connection.prepare('SELECT * FROM results ORDER BY id').all(), testCenters: connection.prepare('SELECT * FROM test_centers ORDER BY school_id, name, id').all(), testRooms: connection.prepare('SELECT * FROM test_rooms ORDER BY center_id, code, id').all(), centerChangeRequests: connection.prepare('SELECT * FROM center_change_requests ORDER BY created_at DESC, id').all(), centerChangeRooms: connection.prepare('SELECT * FROM center_change_rooms ORDER BY request_id, code, id').all(), numberRules: connection.prepare('SELECT * FROM number_rules ORDER BY updated_at DESC, id').all(), numberRuleSegments: connection.prepare('SELECT * FROM number_rule_segments ORDER BY rule_id, position, id').all(), candidateAccountBatches: connection.prepare('SELECT * FROM candidate_account_batches ORDER BY created_at DESC, id').all(), candidateAccountBatchItems: connection.prepare('SELECT * FROM candidate_account_batch_items ORDER BY batch_id, position, id').all(), workflows: connection.prepare('SELECT * FROM workflow_definitions ORDER BY business_type, id').all(), workflowSteps: connection.prepare('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id').all(), workflowInstances: connection.prepare('SELECT * FROM workflow_instances ORDER BY created_at DESC, id').all(), workflowActions: connection.prepare('SELECT * FROM workflow_actions ORDER BY created_at, id').all(), auditLogs: connection.prepare('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC').all() }; } async function readMysqlRows(connection) { const query = async sql => (await connection.execute(sql))[0]; const one = async sql => (await query(sql))[0]; return { system: await one('SELECT * FROM schema_metadata WHERE id = 1'), organization: await one('SELECT * FROM organization WHERE id = 1'), schools: await query('SELECT * FROM schools ORDER BY name, id'), classes: await query('SELECT * FROM school_classes ORDER BY school_id, grade, name, id'), users: await query('SELECT * FROM users ORDER BY created_at, id'), profiles: await query('SELECT * FROM candidate_profiles ORDER BY updated_at, id'), notices: await query('SELECT * FROM notices ORDER BY publish_at, created_at, id'), exams: await query('SELECT * FROM exams ORDER BY created_at, id'), subjects: await query('SELECT * FROM exam_subjects ORDER BY exam_id, position, id'), registrations: await query('SELECT * FROM registrations ORDER BY created_at, id'), registrationSubjects: await query('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id'), admitCards: await query('SELECT * FROM admit_cards ORDER BY registration_id'), results: await query('SELECT * FROM results ORDER BY id'), testCenters: await query('SELECT * FROM test_centers ORDER BY school_id, name, id'), testRooms: await query('SELECT * FROM test_rooms ORDER BY center_id, code, id'), centerChangeRequests: await query('SELECT * FROM center_change_requests ORDER BY created_at DESC, id'), centerChangeRooms: await query('SELECT * FROM center_change_rooms ORDER BY request_id, code, id'), numberRules: await query('SELECT * FROM number_rules ORDER BY updated_at DESC, id'), numberRuleSegments: await query('SELECT * FROM number_rule_segments ORDER BY rule_id, position, id'), candidateAccountBatches: await query('SELECT * FROM candidate_account_batches ORDER BY created_at DESC, id'), candidateAccountBatchItems: await query('SELECT * FROM candidate_account_batch_items ORDER BY batch_id, position, id'), workflows: await query('SELECT * FROM workflow_definitions ORDER BY business_type, id'), workflowSteps: await query('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id'), workflowInstances: await query('SELECT * FROM workflow_instances ORDER BY created_at DESC, id'), workflowActions: await query('SELECT * FROM workflow_actions ORDER BY created_at, id'), auditLogs: await query('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC') }; } function operation(sql, ...params) { return { sql, params }; } function optional(value) { return value == null || value === '' ? null : value; } function auditOperation(log) { return operation( 'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)', log.id, optional(log.actorId), log.action, log.detail, log.createdAt ); } function workflowInstanceOperation(instance) { return operation( `INSERT INTO workflow_instances ( id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, instance.id, instance.workflowId, instance.businessType, instance.businessId, instance.status, Number(instance.currentStep || 1), optional(instance.assigneeId), instance.createdAt, optional(instance.completedAt) ); } function workflowActionOperation(action) { return operation( `INSERT INTO workflow_actions ( id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, action.id, action.instanceId, optional(action.actorId), action.action, optional(action.note), optional(action.fromAssigneeId), optional(action.toAssigneeId), action.createdAt ); } function workflowCreateOperations(instance, action) { return [workflowInstanceOperation(instance), workflowActionOperation(action)]; } function createRepository({ client, location, read, transaction, close }) { return { client, location, read, close, async createCandidate(user, profile, instance, action, log = null) { const operations = [ operation( `INSERT INTO users ( id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, must_change_password, display_name, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, user.id, user.username, optional(user.candidateNumber), user.passwordHash, user.role, optional(user.adminLevel), optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.mustChangePassword ? 1 : 0, user.displayName, user.createdAt ), operation( `INSERT INTO candidate_profiles ( id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address, emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name, guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email), optional(profile.school), optional(profile.grade), optional(profile.schoolId), optional(profile.classId), optional(profile.address), optional(profile.emergencyContact), optional(profile.emergencyPhone), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.status, optional(profile.reviewNote), optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt ) ]; if (instance && action) operations.push(...workflowCreateOperations(instance, action)); if (log) operations.push(auditOperation(log)); await transaction(operations); }, async createCandidateAccountBatch(batch, items, instance, action, log) { const operations = [ operation( `INSERT INTO candidate_account_batches ( id, school_id, requested_by, status, review_note, created_at, reviewed_at ) VALUES (?, ?, ?, ?, ?, ?, ?)`, batch.id, batch.schoolId, optional(batch.requestedBy), batch.status, optional(batch.reviewNote), batch.createdAt, optional(batch.reviewedAt) ) ]; for (const item of items) operations.push(operation( `INSERT INTO candidate_account_batch_items ( id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, item.id, item.batchId, item.classId, Number(item.position), optional(item.candidateNumber), optional(item.initialPassword), optional(item.userId), optional(item.createdAt) )); operations.push(...workflowCreateOperations(instance, action), auditOperation(log)); await transaction(operations); }, async updateCandidateProfile(profile, displayName, instance, action) { const operations = [ operation( `UPDATE candidate_profiles SET name = ?, gender = ?, id_number = ?, phone = ?, email = ?, school = ?, grade = ?, address = ?, school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?, native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?, profile_completed = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ? WHERE id = ?`, profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email), optional(profile.school), optional(profile.grade), optional(profile.address), optional(profile.schoolId), optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status, optional(profile.reviewNote), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt, profile.id ), operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId) ]; if (instance && action) operations.push(...workflowCreateOperations(instance, action)); await transaction(operations); }, async changePassword(user, log) { const operations = [operation( 'UPDATE users SET password_hash = ?, must_change_password = ? WHERE id = ?', user.passwordHash, user.mustChangePassword ? 1 : 0, user.id )]; if (log) operations.push(auditOperation(log)); await transaction(operations); }, async updateRegistrationSetting(enabled, log) { await transaction([ operation('UPDATE schema_metadata SET self_registration_enabled = ? WHERE id = 1', enabled ? 1 : 0), auditOperation(log) ]); }, async createRegistration(registration, instance, action) { const operations = [operation( `INSERT INTO registrations ( id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, registration.id, registration.userId, registration.examId, registration.status, registration.paymentStatus, registration.createdAt, optional(registration.reviewedAt), optional(registration.reviewNote), optional(registration.registrationNumber), optional(registration.numberRuleId) )]; for (const subjectId of registration.subjectIds) { operations.push(operation( 'INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId )); } if (instance && action) operations.push(...workflowCreateOperations(instance, action)); await transaction(operations); }, async reviewCandidate(profile, log) { await transaction([ operation( `UPDATE candidate_profiles SET status = ?, review_note = ?, reviewed_at = ?, reviewer_id = ? WHERE id = ?`, profile.status, optional(profile.reviewNote), profile.reviewedAt, profile.reviewerId, profile.id ), auditOperation(log) ]); }, async reviewRegistration(registration, log) { await transaction([ operation( `UPDATE registrations SET status = ?, payment_status = ?, reviewed_at = ?, review_note = ? WHERE id = ?`, registration.status, registration.paymentStatus, registration.reviewedAt, optional(registration.reviewNote), registration.id ), auditOperation(log) ]); }, async processWorkflow(instance, action, business, log) { const operations = [ operation( `UPDATE workflow_instances SET status = ?, current_step = ?, assignee_id = ?, completed_at = ? WHERE id = ?`, instance.status, Number(instance.currentStep), optional(instance.assigneeId), optional(instance.completedAt), instance.id ), workflowActionOperation(action) ]; if (instance.businessType === 'profile_change') { operations.push(operation( `UPDATE candidate_profiles SET status = ?, review_note = ?, reviewed_at = ?, reviewer_id = ? WHERE id = ?`, business.status, optional(business.reviewNote), optional(business.reviewedAt), optional(business.reviewerId), business.id )); } else if (instance.businessType === 'registration_review') { operations.push(operation( `UPDATE registrations SET status = ?, payment_status = ?, reviewed_at = ?, review_note = ?, registration_number = ?, number_rule_id = ? WHERE id = ?`, business.status, business.paymentStatus, optional(business.reviewedAt), optional(business.reviewNote), optional(business.registrationNumber), optional(business.numberRuleId), business.id )); } else if (instance.businessType === 'center_change') { operations.push(operation( `UPDATE center_change_requests SET status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, business.status, optional(business.reviewNote), optional(business.reviewedAt), business.id )); } else if (instance.businessType === 'candidate_account_batch') { operations.push(operation( `UPDATE candidate_account_batches SET status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, business.status, optional(business.reviewNote), optional(business.reviewedAt), business.id )); } if (log) operations.push(auditOperation(log)); await transaction(operations); }, async completeCandidateAccountBatch(batch, items, users, profiles, instance, action, log) { const operations = [ operation( `UPDATE workflow_instances SET status = ?, current_step = ?, assignee_id = ?, completed_at = ? WHERE id = ?`, instance.status, Number(instance.currentStep), optional(instance.assigneeId), optional(instance.completedAt), instance.id ), workflowActionOperation(action), operation( `UPDATE candidate_account_batches SET status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, batch.status, optional(batch.reviewNote), optional(batch.reviewedAt), batch.id ) ]; for (let index = 0; index < users.length; index += 1) { const user = users[index]; const profile = profiles[index]; const item = items[index]; operations.push( operation( `INSERT INTO users ( id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, must_change_password, display_name, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, user.id, user.username, user.candidateNumber, user.passwordHash, user.role, optional(user.adminLevel), user.schoolId, user.classId, 1, 1, user.displayName, user.createdAt ), operation( `INSERT INTO candidate_profiles ( id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address, emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name, guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email), profile.school, profile.grade, profile.schoolId, profile.classId, optional(profile.address), optional(profile.emergencyContact), optional(profile.emergencyPhone), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), 0, profile.status, optional(profile.reviewNote), optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt ), operation( `UPDATE candidate_account_batch_items SET candidate_number = ?, initial_password = ?, user_id = ?, created_at = ? WHERE id = ?`, item.candidateNumber, item.initialPassword, item.userId, item.createdAt, item.id ) ); } operations.push(auditOperation(log)); await transaction(operations); }, async transferWorkflow(instance, action, log) { const operations = [ operation('UPDATE workflow_instances SET assignee_id = ? WHERE id = ?', optional(instance.assigneeId), instance.id), workflowActionOperation(action) ]; if (log) operations.push(auditOperation(log)); await transaction(operations); }, async saveWorkflow(workflow, log) { const operations = [ operation( `UPDATE workflow_definitions SET name = ?, active = ?, updated_by = ?, updated_at = ? WHERE id = ?`, workflow.name, workflow.active ? 1 : 0, optional(workflow.updatedBy), workflow.updatedAt, workflow.id ), operation('DELETE FROM workflow_steps WHERE workflow_id = ?', workflow.id) ]; workflow.steps.forEach((step, index) => operations.push(operation( `INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)`, step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel ))); operations.push(auditOperation(log)); await transaction(operations); }, async saveNumberRule(rule, isNew, log) { const operations = [operation('UPDATE number_rules SET active = 0 WHERE active = 1')]; if (isNew) { operations.push(operation( 'INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', rule.id, rule.name, rule.separator, rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt )); } else { operations.push( operation( 'UPDATE number_rules SET name = ?, separator = ?, active = ?, created_by = ?, updated_at = ? WHERE id = ?', rule.name, rule.separator, rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt, rule.id ), operation('DELETE FROM number_rule_segments WHERE rule_id = ?', rule.id) ); } rule.segments.forEach((segment, index) => operations.push(operation( `INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)`, segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0) ))); operations.push(auditOperation(log)); await transaction(operations); }, async createAdmin(user, log) { await transaction([ operation( `INSERT INTO users ( id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at ) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`, user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt ), auditOperation(log) ]); }, async saveTestCenter(center, isNew, log) { const centerOperation = isNew ? operation( `INSERT INTO test_centers ( id, school_id, name, address, contact, rooms, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?)`, center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms, center.updatedAt ) : operation( `UPDATE test_centers SET name = ?, address = ?, contact = ?, rooms = ?, updated_at = ? WHERE id = ?`, center.name, center.address, optional(center.contact), center.rooms, center.updatedAt, center.id ); await transaction([centerOperation, auditOperation(log)]); }, async createCenterChangeRequest(request, rooms, instance, action, log) { const operations = [ operation( `INSERT INTO center_change_requests ( id, center_id, school_id, request_type, code, name, address, contact, manager_name, manager_phone, emergency_phone, gate_open_time, transport, center_status, notes, status, review_note, requested_by, created_at, reviewed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, request.id, optional(request.centerId), request.schoolId, request.requestType, request.code, request.name, request.address, optional(request.contact), optional(request.managerName), optional(request.managerPhone), optional(request.emergencyPhone), optional(request.gateOpenTime), optional(request.transport), request.centerStatus, optional(request.notes), request.status, optional(request.reviewNote), optional(request.requestedBy), request.createdAt, optional(request.reviewedAt) ), ...workflowCreateOperations(instance, action) ]; for (const room of rooms) operations.push(operation( `INSERT INTO center_change_rooms ( id, request_id, room_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.id, request.id, optional(room.roomId), 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) )); operations.push(auditOperation(log)); await transaction(operations); }, async applyCenterChange(request, instance, action, center, rooms, log) { const operations = [ operation( `UPDATE workflow_instances SET status = ?, current_step = ?, assignee_id = ?, completed_at = ? WHERE id = ?`, instance.status, Number(instance.currentStep), optional(instance.assigneeId), optional(instance.completedAt), instance.id ), workflowActionOperation(action), operation( `UPDATE center_change_requests SET status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, request.status, optional(request.reviewNote), optional(request.reviewedAt), request.id ) ]; if (request.status === 'approved' && center) { if (request.requestType === 'create') { operations.push(operation( `INSERT INTO test_centers ( id, school_id, code, name, address, contact, manager_name, manager_phone, emergency_phone, gate_open_time, transport, status, notes, rooms, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, center.id, center.schoolId, center.code, center.name, center.address, optional(center.contact), optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), optional(center.gateOpenTime), optional(center.transport), center.status, optional(center.notes), center.rooms || '', center.updatedAt )); } else { operations.push(operation( `UPDATE test_centers SET code = ?, name = ?, address = ?, contact = ?, manager_name = ?, manager_phone = ?, emergency_phone = ?, gate_open_time = ?, transport = ?, status = ?, notes = ?, rooms = ?, updated_at = ? WHERE id = ?`, center.code, center.name, center.address, optional(center.contact), optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), optional(center.gateOpenTime), optional(center.transport), center.status, optional(center.notes), center.rooms || '', center.updatedAt, center.id )); } operations.push(operation('DELETE FROM test_rooms WHERE center_id = ?', center.id)); for (const room of rooms) operations.push(operation( `INSERT INTO test_rooms ( id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.id, center.id, 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) )); } operations.push(auditOperation(log)); await transaction(operations); }, async createAdmitCard(registrationId, admitCard, log) { await transaction([ operation( `INSERT INTO admit_cards ( registration_id, card_number, test_center, room, seat, generated_at ) VALUES (?, ?, ?, ?, ?, ?)`, registrationId, admitCard.number, admitCard.testCenter, admitCard.room, admitCard.seat, admitCard.generatedAt ), auditOperation(log) ]); }, async createExam(exam, log) { const operations = [operation( `INSERT INTO exams ( id, code, name, description, registration_start, registration_end, exam_start, exam_end, admit_download_start, admit_download_end, location, status, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd, exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '', exam.status, exam.createdAt )]; exam.subjects.forEach((subject, index) => operations.push(operation( `INSERT INTO exam_subjects ( id, exam_id, name, subject_date, start_time, end_time, fee, position ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.order || index + 1) ))); operations.push(auditOperation(log)); await transaction(operations); }, async updateExam(exam, log, replaceSubjects = false) { const operations = [ operation( `UPDATE exams SET code = ?, name = ?, description = ?, registration_start = ?, registration_end = ?, exam_start = ?, exam_end = ?, admit_download_start = ?, admit_download_end = ?, location = ?, status = ? WHERE id = ?`, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd, exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '', exam.status, exam.id ) ]; if (replaceSubjects) { operations.push(operation('DELETE FROM exam_subjects WHERE exam_id = ?', exam.id)); exam.subjects.forEach((subject, index) => operations.push(operation( `INSERT INTO exam_subjects ( id, exam_id, name, subject_date, start_time, end_time, fee, position ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.order || index + 1) ))); } operations.push(auditOperation(log)); await transaction(operations); }, async createNotice(notice, log) { await transaction([ operation( `INSERT INTO notices ( id, title, summary, content, category, pinned, status, publish_at, created_at, author ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, notice.id, notice.title, notice.summary, notice.content, notice.category, notice.pinned ? 1 : 0, notice.status, optional(notice.publishAt), optional(notice.createdAt), notice.author ), auditOperation(log) ]); }, async updateNotice(notice, log) { await transaction([ operation( `UPDATE notices SET title = ?, summary = ?, content = ?, category = ?, pinned = ?, status = ?, publish_at = ? WHERE id = ?`, notice.title, notice.summary, notice.content, notice.category, notice.pinned ? 1 : 0, notice.status, optional(notice.publishAt), notice.id ), auditOperation(log) ]); }, async saveResult(result, isNew, log) { const resultOperation = isNew ? operation( `INSERT INTO results ( id, registration_id, subject_id, score, grade, published, updated_at, published_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, result.id, result.registrationId, result.subjectId, result.score, result.grade, result.published ? 1 : 0, optional(result.updatedAt), optional(result.publishedAt) ) : operation( `UPDATE results SET score = ?, grade = ?, published = ?, updated_at = ?, published_at = ? WHERE id = ?`, result.score, result.grade, result.published ? 1 : 0, optional(result.updatedAt), optional(result.publishedAt), result.id ); await transaction([resultOperation, auditOperation(log)]); } }; } 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'] ]); 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 (!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, 5, ?, ?, ?) `).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() }); } async function createMysqlStore({ seed }) { const { default: mysql } = await import('mysql2/promise'); const connectionUrl = process.env.DATABASE_URL; const database = process.env.MYSQL_DATABASE; if (!connectionUrl && (!process.env.MYSQL_HOST || !process.env.MYSQL_USER || !database)) { throw new Error('MySQL 配置不完整:请设置 DATABASE_URL,或 MYSQL_HOST、MYSQL_USER、MYSQL_DATABASE'); } const pool = connectionUrl ? mysql.createPool(connectionUrl) : mysql.createPool({ host: process.env.MYSQL_HOST, port: Number(process.env.MYSQL_PORT || 3306), user: process.env.MYSQL_USER, password: process.env.MYSQL_PASSWORD || '', database, waitForConnections: true, connectionLimit: Number(process.env.MYSQL_CONNECTION_LIMIT || 10), charset: 'utf8mb4', timezone: 'Z', enableKeepAlive: true }); for (const statement of mysqlSchema) await pool.execute(statement); const mysqlColumnMigrations = [ "ALTER TABLE users ADD COLUMN IF NOT EXISTS admin_level ENUM('super', 'school', 'class') NULL", 'ALTER TABLE users ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL', 'ALTER TABLE users ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL', 'ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE', 'ALTER TABLE users ADD COLUMN IF NOT EXISTS candidate_number VARCHAR(120) NULL', 'ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE', 'ALTER TABLE schema_metadata ADD COLUMN IF NOT EXISTS self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE', 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL', 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL', 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS native_place VARCHAR(160) NULL', 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS birth_date VARCHAR(20) NULL', 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS ethnicity VARCHAR(60) NULL', 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS postal_code VARCHAR(20) NULL', 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_name VARCHAR(100) NULL', 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_phone VARCHAR(60) NULL', 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS profile_completed BOOLEAN NOT NULL DEFAULT FALSE', 'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS registration_number VARCHAR(120) NULL', 'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL', 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS code VARCHAR(40) NULL', 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_name VARCHAR(100) NULL', 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_phone VARCHAR(60) NULL', 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS emergency_phone VARCHAR(60) NULL', 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS gate_open_time VARCHAR(40) NULL', 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS transport VARCHAR(500) NULL', "ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS status ENUM('active', 'inactive') NOT NULL DEFAULT 'active'", 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS notes VARCHAR(1000) NULL', "ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL" ]; for (const statement of mysqlColumnMigrations) await pool.execute(statement); const [legacyRegistrationNumberIndexes] = await pool.execute("SHOW INDEX FROM registrations WHERE Key_name = 'uq_registrations_number'"); if (legacyRegistrationNumberIndexes.length) await pool.execute('ALTER TABLE registrations DROP INDEX uq_registrations_number'); const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1'); if (existing.length) { const [metadataRows] = await pool.execute('SELECT app_version FROM schema_metadata WHERE id = 1'); if (Number(metadataRows[0]?.app_version || 1) < 2) { const extension = seed(); const connection = await pool.getConnection(); try { await connection.beginTransaction(); for (const school of extension.schools) await connection.execute( 'INSERT IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)', [school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1] ); for (const schoolClass of extension.classes) await connection.execute( 'INSERT IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)', [schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1] ); await connection.execute("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, TRUE) WHERE role = 'admin'"); for (const user of extension.users.filter(item => item.role === 'admin')) await connection.execute( `INSERT IGNORE INTO users ( id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at ) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`, [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) await connection.execute( `UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?) WHERE school = ? AND grade = ?`, [optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade] ); const [centerRows] = await connection.execute('SELECT id FROM test_centers LIMIT 1'); if (!centerRows.length) for (const center of extension.testCenters) await connection.execute( 'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt] ); const [ruleRows] = await connection.execute('SELECT id FROM number_rules LIMIT 1'); if (!ruleRows.length) for (const rule of extension.numberRules) { await connection.execute( 'INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt] ); for (const [index, segment] of rule.segments.entries()) await connection.execute( 'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)', [segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0)] ); } const [workflowRows] = await connection.execute('SELECT id FROM workflow_definitions LIMIT 1'); if (!workflowRows.length) for (const workflow of extension.workflows) { await connection.execute( 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt] ); for (const [index, step] of workflow.steps.entries()) await connection.execute( 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', [step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel] ); } await connection.execute('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1'); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } if (Number(metadataRows[0]?.app_version || 1) < 3) { const extension = seed(); const connection = await pool.getConnection(); try { await connection.beginTransaction(); for (const center of extension.testCenters) await connection.execute( `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 = ?`, [center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id] ); await connection.execute("UPDATE test_centers SET code = CONCAT('CENTER-', RIGHT(id, 8)) WHERE code IS NULL OR code = ''"); const [roomRows] = await connection.execute('SELECT id FROM test_rooms LIMIT 1'); if (!roomRows.length) for (const room of extension.testRooms) await connection.execute( `INSERT INTO test_rooms ( id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [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'); const [centerWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1"); if (centerWorkflow && !centerWorkflowRows.length) { await connection.execute( 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt] ); for (const [index, step] of centerWorkflow.steps.entries()) await connection.execute( 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', [step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel] ); } await connection.execute('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1'); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } if (Number(metadataRows[0]?.app_version || 1) < 4) { const extension = seed(); const connection = await pool.getConnection(); try { await connection.beginTransaction(); for (const user of extension.users.filter(item => item.role === 'candidate')) await connection.execute( `UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?), must_change_password = COALESCE(must_change_password, ?) WHERE id = ?`, [optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id] ); await connection.execute(`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), CONCAT('CAND-', RIGHT(id, 10)) ) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`); for (const profile of extension.candidateProfiles) await connection.execute( `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 = ?`, [optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id] ); await connection.execute(`UPDATE registrations JOIN users ON users.id = registrations.user_id SET registrations.registration_number = users.candidate_number WHERE registrations.registration_number IS NULL OR registrations.registration_number = ''`); await connection.execute('UPDATE schema_metadata SET schema_version = 4, app_version = 4 WHERE id = 1'); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } if (Number(metadataRows[0]?.app_version || 1) < 5) { const extension = seed(); const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch'); const connection = await pool.getConnection(); try { await connection.beginTransaction(); const [batchWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1"); if (batchWorkflow && !batchWorkflowRows.length) { await connection.execute( 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt] ); for (const [index, step] of batchWorkflow.steps.entries()) await connection.execute( 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', [step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel] ); } await connection.execute('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1'); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } } if (!existing.length) { const initialState = seed(); const connection = await pool.getConnection(); try { await connection.beginTransaction(); const [insert] = await connection.execute(` INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) VALUES (1, 5, ?, ?, ?) `, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]); if (insert.affectedRows === 1) { for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); } await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } const [centerCodeIndexes] = await pool.execute("SHOW INDEX FROM test_centers WHERE Key_name = 'uq_centers_code'"); if (!centerCodeIndexes.length) { await pool.execute('ALTER TABLE test_centers MODIFY COLUMN code VARCHAR(40) NOT NULL, ADD UNIQUE KEY uq_centers_code (code)'); } const [candidateNumberIndexes] = await pool.execute("SHOW INDEX FROM users WHERE Key_name = 'uq_users_candidate_number'"); if (!candidateNumberIndexes.length) { await pool.execute('ALTER TABLE users ADD UNIQUE KEY uq_users_candidate_number (candidate_number)'); } const transaction = async operations => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); for (const item of operations) await connection.execute(item.sql, item.params); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } }; const read = async () => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const state = stateFromRows(await readMysqlRows(connection)); await connection.commit(); return state; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } }; return createRepository({ client: 'mysql', location: connectionUrl ? 'DATABASE_URL' : `${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT || 3306}/${database}`, read, transaction, close: async () => pool.end() }); } export async function createDatabase({ root, seed }) { const client = String(process.env.DATABASE_CLIENT || (process.env.NODE_ENV === 'production' ? 'mysql' : 'sqlite')).toLowerCase(); if (!['sqlite', 'mysql'].includes(client)) { throw new Error(`不支持的 DATABASE_CLIENT:${client}(可选 sqlite 或 mysql)`); } if (client === 'mysql') return createMysqlStore({ seed }); const path = resolve(process.env.SQLITE_PATH || join(root, 'data', 'exam.sqlite')); return createSqliteStore({ path, seed }); }