Files
Exam-Information-System/database.mjs
T

2274 lines
107 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
'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 workflow_definitions (
id TEXT PRIMARY KEY,
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change')),
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_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 workflow_definitions (
id VARCHAR(64) NOT NULL,
business_type ENUM('profile_change', 'registration_review', 'center_change') 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', '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 = 4, 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 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) || []
})),
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(),
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'),
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 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 {
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
));
}
if (log) 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 assignRegistrationNumber(registration, log) {
await transaction([
operation(
'UPDATE registrations SET registration_number = ?, number_rule_id = ? WHERE id = ?',
registration.registrationNumber, optional(registration.numberRuleId), registration.id
),
auditOperation(log)
]);
},
async assignCandidateNumbers(users, registrations, log) {
const operations = users.map(user => operation(
'UPDATE users SET candidate_number = ? WHERE id = ? AND (candidate_number IS NULL OR candidate_number = \'\')',
user.candidateNumber, user.id
));
operations.push(...registrations.map(registration => operation(
`UPDATE registrations SET registration_number = ?, number_rule_id = ?
WHERE id = ? AND (registration_number IS NULL OR registration_number = '')`,
registration.registrationNumber, optional(registration.numberRuleId), registration.id
)));
operations.push(auditOperation(log));
await transaction(operations);
},
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('center_change')) {
connection.exec(`
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
CREATE TABLE workflow_definitions_v3 (
id TEXT PRIMARY KEY,
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change')),
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_v3 (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_v3 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 (!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, 4, ?, ?, ?)
`).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') 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 (!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, 4, ?, ?, ?)
`, [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 });
}