-- ============================================================================= -- SÁNG KIẾN (INNOVATION APPLICATION) DATABASE SCHEMA -- PostgreSQL 14+ -- -- Domain: Manage innovation applications at ĐHYD TP.HCM (Vietnamese medical -- university). Supports the full lifecycle: draft → submit → evaluate → approve. -- -- Design principles: -- - 3NF for entities, JSONB for semi-structured/optional narrative -- - Soft delete (deleted_at) — legal/audit requires historical retention -- - State machine on applications.status enforced by trigger -- - Full audit_log via trigger on all CUD operations -- - Contribution % sums to 100 enforced by DEFERRABLE trigger -- ============================================================================= CREATE EXTENSION IF NOT EXISTS pg_trgm; -- fuzzy matching CREATE EXTENSION IF NOT EXISTS unaccent; -- Vietnamese diacritics in search -- Convenience: updated_at auto-maintenance CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at := NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- ============================================================================= -- REFERENCE: UNITS (departments, faculties, centers) -- ============================================================================= CREATE TABLE units ( unit_id SERIAL PRIMARY KEY, code VARCHAR(32) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, -- full Vietnamese name parent_unit_id INT REFERENCES units(unit_id) ON DELETE SET NULL, type VARCHAR(32) NOT NULL CHECK (type IN ('TRUONG','KHOA','PHONG','BO_MON','TRUNG_TAM','KHAC')), is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TRIGGER trg_units_touch BEFORE UPDATE ON units FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); -- ============================================================================= -- USERS (unified: authors, evaluators, admins — a user can wear many hats) -- ============================================================================= CREATE TABLE users ( user_id SERIAL PRIMARY KEY, full_name VARCHAR(255) NOT NULL, title VARCHAR(64), -- PGS.TS, TS., GS., CN., ThS. date_of_birth DATE, email VARCHAR(255) UNIQUE, phone VARCHAR(32), id_number VARCHAR(32) UNIQUE, -- CCCD / hộ chiếu unit_id INT REFERENCES units(unit_id) ON DELETE SET NULL, position VARCHAR(255), -- chức danh: Trưởng phòng, GV cao cấp qualification VARCHAR(64), -- trình độ: Tiến sĩ, Thạc sĩ, Cử nhân user_type VARCHAR(32) NOT NULL DEFAULT 'AUTHOR' CHECK (user_type IN ('AUTHOR','COUNCIL','ADMIN','STUDENT','EXTERNAL')), is_active BOOLEAN NOT NULL DEFAULT TRUE, deleted_at TIMESTAMPTZ, -- soft delete created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_users_unit ON users(unit_id); CREATE INDEX idx_users_active ON users(is_active) WHERE deleted_at IS NULL; CREATE INDEX idx_users_name_trgm ON users USING GIN (full_name gin_trgm_ops); CREATE TRIGGER trg_users_touch BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); -- ============================================================================= -- APPLICATIONS (sáng kiến) — the core entity -- ============================================================================= CREATE TABLE applications ( application_id SERIAL PRIMARY KEY, code VARCHAR(32) UNIQUE NOT NULL, -- e.g., 'SK-2025-001' title TEXT NOT NULL, title_en TEXT, registration_year INT NOT NULL CHECK (registration_year BETWEEN 2000 AND 2100), field_of_application TEXT, -- lĩnh vực áp dụng -- Workflow state (enforced by trigger below) status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' CHECK (status IN ( 'DRAFT','SUBMITTED','UNDER_REVIEW', 'EVALUATED','APPROVED','REJECTED','WITHDRAWN' )), -- Mẫu 01 narrative (long text) introduction TEXT, -- 1. Mở đầu current_state TEXT, -- 4.1 Tình trạng đã biết purpose TEXT, -- Mục đích implementation_steps TEXT, -- Các bước thực hiện required_conditions TEXT, -- Điều kiện cần thiết results_achieved TEXT, -- Kết quả thu được novelty_description TEXT, -- Tính mới confidential_info TEXT, -- Thông tin cần bảo mật -- 10 effectiveness sub-fields (all optional narrative) → JSONB effectiveness JSONB NOT NULL DEFAULT '{}'::jsonb, -- Shape: { "economic":"...", "teaching":"...", "productivity":"...", -- "work_efficiency":"...", "quality":"...", "cost_reduction":"...", -- "environment":"...", "health":"...", "safety":"...", "awareness":"..." } -- Mẫu 02 fields owner_org VARCHAR(255), -- chủ đầu tư first_applied_date DATE, -- ngày áp dụng lần đầu content_summary TEXT, -- nội dung sáng kiến (short) author_assessment TEXT, -- đánh giá theo tác giả org_assessment TEXT, -- đánh giá theo tổ chức -- Mẫu 02 classification (mutually exclusive in form, but stored as flags) is_technical_solution BOOLEAN NOT NULL DEFAULT FALSE, is_from_research_article BOOLEAN NOT NULL DEFAULT FALSE, is_from_book_material BOOLEAN NOT NULL DEFAULT FALSE, CONSTRAINT chk_exactly_one_classification CHECK ( status = 'DRAFT' OR (is_technical_solution::int + is_from_research_article::int + is_from_book_material::int) = 1 ), -- Workflow timestamps submitted_at TIMESTAMPTZ, decided_at TIMESTAMPTZ, primary_unit_id INT REFERENCES units(unit_id), created_by INT REFERENCES users(user_id), deleted_at TIMESTAMPTZ, -- soft delete created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_apps_status ON applications(status) WHERE deleted_at IS NULL; CREATE INDEX idx_apps_year ON applications(registration_year); CREATE INDEX idx_apps_unit ON applications(primary_unit_id); CREATE INDEX idx_apps_title_trgm ON applications USING GIN (title gin_trgm_ops); CREATE INDEX idx_apps_fts ON applications USING GIN ( to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(introduction,'') || ' ' || coalesce(novelty_description,'') ) ); CREATE INDEX idx_apps_effectiveness ON applications USING GIN (effectiveness); CREATE TRIGGER trg_apps_touch BEFORE UPDATE ON applications FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); -- ============================================================================= -- APPLICATION_AUTHORS (M:N with contribution %) -- ============================================================================= CREATE TABLE application_authors ( application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, user_id INT NOT NULL REFERENCES users(user_id), contribution_pct NUMERIC(5,2) NOT NULL CHECK (contribution_pct > 0 AND contribution_pct <= 100), role VARCHAR(32) NOT NULL DEFAULT 'CO_AUTHOR' CHECK (role IN ('PRIMARY','CO_AUTHOR')), display_order INT NOT NULL DEFAULT 0, PRIMARY KEY (application_id, user_id) ); CREATE INDEX idx_app_authors_user ON application_authors(user_id); -- At most one PRIMARY author per application CREATE UNIQUE INDEX uq_primary_per_app ON application_authors(application_id) WHERE role = 'PRIMARY'; -- Deferrable check: contribution % must total 100 per application CREATE OR REPLACE FUNCTION check_contribution_total() RETURNS TRIGGER AS $$ DECLARE v_total NUMERIC; v_app INT; BEGIN v_app := COALESCE(NEW.application_id, OLD.application_id); SELECT COALESCE(SUM(contribution_pct),0) INTO v_total FROM application_authors WHERE application_id = v_app; -- Only enforce when application has left DRAFT IF (SELECT status FROM applications WHERE application_id = v_app) <> 'DRAFT' AND v_total <> 100 THEN RAISE EXCEPTION 'Contribution % for application % must sum to 100 (got %)', '%', v_app, v_total; END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE CONSTRAINT TRIGGER trg_contribution_total AFTER INSERT OR UPDATE OR DELETE ON application_authors DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_contribution_total(); -- ============================================================================= -- ORGS that tested / adopted the innovation (Mẫu 01 inner table) -- ============================================================================= CREATE TABLE application_adopters ( adopter_id SERIAL PRIMARY KEY, application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, display_order INT NOT NULL DEFAULT 0, org_name VARCHAR(255) NOT NULL, address TEXT, field TEXT ); CREATE INDEX idx_adopters_app ON application_adopters(application_id); -- ============================================================================= -- PARTICIPANTS in first application (Mẫu 02 inner table) -- ============================================================================= CREATE TABLE application_participants ( participant_id SERIAL PRIMARY KEY, application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, user_id INT REFERENCES users(user_id), -- optional link display_order INT NOT NULL DEFAULT 0, full_name VARCHAR(255) NOT NULL, date_of_birth DATE, work_unit VARCHAR(255), position VARCHAR(255), qualification VARCHAR(64), support_content TEXT ); CREATE INDEX idx_participants_app ON application_participants(application_id); -- ============================================================================= -- EVALUATIONS (Mẫu 04) — council members score applications -- ============================================================================= CREATE TABLE evaluations ( evaluation_id SERIAL PRIMARY KEY, application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, evaluator_id INT NOT NULL REFERENCES users(user_id), novelty_comments TEXT, novelty_score INT NOT NULL DEFAULT 0 CHECK (novelty_score BETWEEN 0 AND 40), effectiveness_comments TEXT, effectiveness_score INT NOT NULL DEFAULT 0 CHECK (effectiveness_score BETWEEN 0 AND 60), total_score INT GENERATED ALWAYS AS (novelty_score + effectiveness_score) STORED, conclusion TEXT, evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (application_id, evaluator_id) ); CREATE INDEX idx_eval_app ON evaluations(application_id); CREATE INDEX idx_eval_evaluator ON evaluations(evaluator_id); -- ============================================================================= -- COMMITMENTS (Bản cam kết) — for paper-based innovations -- ============================================================================= CREATE TABLE commitments ( commitment_id SERIAL PRIMARY KEY, application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, user_id INT NOT NULL REFERENCES users(user_id), paper_title TEXT, role_type VARCHAR(32) NOT NULL CHECK (role_type IN ('PRIMARY_AUTHOR','CO_AUTHOR')), -- 5 commitment checkboxes is_legal_owner BOOLEAN NOT NULL DEFAULT FALSE, is_authorized_by_owner BOOLEAN NOT NULL DEFAULT FALSE, has_coauthor_consent BOOLEAN NOT NULL DEFAULT FALSE, not_predatory_journal BOOLEAN NOT NULL DEFAULT FALSE, complies_with_ip_law BOOLEAN NOT NULL DEFAULT FALSE, signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (application_id, user_id) ); CREATE INDEX idx_commit_app ON commitments(application_id); -- ============================================================================= -- ATTACHMENTS (uploaded files — figures, flowcharts, annexes) -- ============================================================================= CREATE TABLE attachments ( attachment_id SERIAL PRIMARY KEY, application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, file_name VARCHAR(255) NOT NULL, file_path TEXT NOT NULL, -- S3/MinIO key file_size BIGINT, mime_type VARCHAR(128), kind VARCHAR(32) -- 'LUU_DO', 'PHU_LUC', 'KY_SO', 'KHAC' CHECK (kind IS NULL OR kind IN ('LUU_DO','PHU_LUC','KY_SO','KHAC')), uploaded_by INT REFERENCES users(user_id), uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_attach_app ON attachments(application_id); -- ============================================================================= -- AUDIT LOG — single table, populated by triggers on all CUD operations -- ============================================================================= CREATE TABLE audit_log ( log_id BIGSERIAL PRIMARY KEY, table_name VARCHAR(64) NOT NULL, record_id TEXT NOT NULL, action VARCHAR(16) NOT NULL CHECK (action IN ('INSERT','UPDATE','DELETE')), changed_by INT, -- set from app via SET LOCAL my.user_id changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), old_data JSONB, new_data JSONB ); CREATE INDEX idx_audit_table_record ON audit_log(table_name, record_id); CREATE INDEX idx_audit_user_time ON audit_log(changed_by, changed_at DESC); -- Generic audit trigger function CREATE OR REPLACE FUNCTION audit_trigger() RETURNS TRIGGER AS $$ DECLARE v_user INT; v_pk TEXT; BEGIN -- Get user_id from session var if app sets it; else NULL BEGIN v_user := current_setting('my.user_id')::INT; EXCEPTION WHEN OTHERS THEN v_user := NULL; END; v_pk := COALESCE( (row_to_json(NEW)::jsonb->>TG_ARGV[0]), (row_to_json(OLD)::jsonb->>TG_ARGV[0]) ); INSERT INTO audit_log(table_name, record_id, action, changed_by, old_data, new_data) VALUES ( TG_TABLE_NAME, v_pk, TG_OP, v_user, CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) END, CASE WHEN TG_OP IN ('INSERT','UPDATE') THEN to_jsonb(NEW) END ); RETURN COALESCE(NEW, OLD); END; $$ LANGUAGE plpgsql; -- Attach audit trigger to the important tables (pass PK column name as arg) CREATE TRIGGER trg_audit_applications AFTER INSERT OR UPDATE OR DELETE ON applications FOR EACH ROW EXECUTE FUNCTION audit_trigger('application_id'); CREATE TRIGGER trg_audit_authors AFTER INSERT OR UPDATE OR DELETE ON application_authors FOR EACH ROW EXECUTE FUNCTION audit_trigger('application_id'); CREATE TRIGGER trg_audit_evaluations AFTER INSERT OR UPDATE OR DELETE ON evaluations FOR EACH ROW EXECUTE FUNCTION audit_trigger('evaluation_id'); CREATE TRIGGER trg_audit_commitments AFTER INSERT OR UPDATE OR DELETE ON commitments FOR EACH ROW EXECUTE FUNCTION audit_trigger('commitment_id'); -- ============================================================================= -- WORKFLOW STATE MACHINE ENFORCEMENT -- ============================================================================= CREATE OR REPLACE FUNCTION enforce_application_transitions() RETURNS TRIGGER AS $$ DECLARE allowed BOOLEAN := FALSE; BEGIN IF OLD.status = NEW.status THEN RETURN NEW; END IF; -- Allowed transitions allowed := CASE WHEN OLD.status = 'DRAFT' AND NEW.status IN ('SUBMITTED','WITHDRAWN') THEN TRUE WHEN OLD.status = 'SUBMITTED' AND NEW.status IN ('UNDER_REVIEW','WITHDRAWN','DRAFT') THEN TRUE WHEN OLD.status = 'UNDER_REVIEW' AND NEW.status IN ('EVALUATED','WITHDRAWN') THEN TRUE WHEN OLD.status = 'EVALUATED' AND NEW.status IN ('APPROVED','REJECTED') THEN TRUE ELSE FALSE END; IF NOT allowed THEN RAISE EXCEPTION 'Invalid status transition: % → %', OLD.status, NEW.status; END IF; -- Auto-set timestamps IF NEW.status = 'SUBMITTED' AND OLD.status = 'DRAFT' THEN NEW.submitted_at := NOW(); END IF; IF NEW.status IN ('APPROVED','REJECTED') THEN NEW.decided_at := NOW(); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_app_state_machine BEFORE UPDATE OF status ON applications FOR EACH ROW EXECUTE FUNCTION enforce_application_transitions(); -- ============================================================================= -- CONVENIENCE VIEWS -- ============================================================================= -- Dashboard: applications with author names and current evaluation average CREATE VIEW v_application_summary AS SELECT a.application_id, a.code, a.title, a.status, a.registration_year, u.name AS primary_unit_name, (SELECT string_agg(usr.full_name, ', ' ORDER BY aa.display_order) FROM application_authors aa JOIN users usr ON usr.user_id = aa.user_id WHERE aa.application_id = a.application_id) AS author_names, (SELECT ROUND(AVG(total_score),2) FROM evaluations WHERE application_id = a.application_id) AS avg_score, (SELECT COUNT(*) FROM evaluations WHERE application_id = a.application_id) AS num_evaluations, a.submitted_at, a.decided_at FROM applications a LEFT JOIN units u ON u.unit_id = a.primary_unit_id WHERE a.deleted_at IS NULL; -- Materialized view: annual approval statistics (refresh nightly) CREATE MATERIALIZED VIEW mv_annual_stats AS SELECT registration_year, COUNT(*) FILTER (WHERE status = 'APPROVED') AS approved, COUNT(*) FILTER (WHERE status = 'REJECTED') AS rejected, COUNT(*) FILTER (WHERE status NOT IN ('APPROVED','REJECTED')) AS pending, COUNT(*) AS total FROM applications WHERE deleted_at IS NULL GROUP BY registration_year; CREATE UNIQUE INDEX ON mv_annual_stats(registration_year);