sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
-- Initiative Recognition System — PostgreSQL schema (architecture_plan.md §4)
|
||||
-- Table order respects FKs (units before users).
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
|
||||
-- ========= ENUMS =========
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE user_role AS ENUM ('applicant','council_member','editor','admin','viewer');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE initiative_class AS ENUM ('technical','research','textbook');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE research_evidence AS ENUM ('international','domestic','poster');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE eval_level AS ENUM ('high','medium','low');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE submission_status AS ENUM ('draft','submitted','under_review','approved','rejected');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE recognition_tier AS ENUM ('excellent','good');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- ========= IDENTITY =========
|
||||
CREATE TABLE IF NOT EXISTS units (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
parent_id UUID REFERENCES units(id),
|
||||
address TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email CITEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
unit_id UUID REFERENCES units(id),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role user_role NOT NULL,
|
||||
PRIMARY KEY (user_id, role)
|
||||
);
|
||||
|
||||
-- System user for anonymous draft saves (no login yet)
|
||||
INSERT INTO users (id, email, password_hash, full_name)
|
||||
VALUES (
|
||||
'00000000-0000-4000-8000-000000000001',
|
||||
'system@draft.local',
|
||||
'-',
|
||||
'System (draft owner)'
|
||||
)
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
-- ========= CASE / INITIATIVE ROOT =========
|
||||
CREATE TABLE IF NOT EXISTS initiatives (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
case_code TEXT UNIQUE NOT NULL,
|
||||
owner_id UUID NOT NULL REFERENCES users(id),
|
||||
status submission_status NOT NULL DEFAULT 'draft',
|
||||
recognition_tier recognition_tier,
|
||||
submitted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_initiatives_owner_status ON initiatives(owner_id, status);
|
||||
|
||||
-- ========= DRAFT SNAPSHOTS =========
|
||||
CREATE TABLE IF NOT EXISTS drafts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
draft_code TEXT UNIQUE NOT NULL,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
payload JSONB NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_drafts_initiative ON drafts(initiative_id);
|
||||
|
||||
-- ========= ĐƠN (APPLICATION) =========
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
initiative_name TEXT NOT NULL,
|
||||
investor_name TEXT,
|
||||
application_field TEXT,
|
||||
first_apply_date DATE,
|
||||
initiative_classification initiative_class,
|
||||
research_evidence_kind research_evidence,
|
||||
international_journal_decl TEXT,
|
||||
content_summary TEXT,
|
||||
confidential_info TEXT,
|
||||
conditions TEXT,
|
||||
author_evaluation TEXT,
|
||||
trial_evaluation TEXT,
|
||||
submission_day SMALLINT,
|
||||
submission_month SMALLINT,
|
||||
submission_year SMALLINT,
|
||||
honesty_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
CONSTRAINT chk_first_apply_window
|
||||
CHECK (first_apply_date IS NULL
|
||||
OR first_apply_date BETWEEN DATE '2025-04-15' AND DATE '2026-04-15')
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS authors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id),
|
||||
ordinal SMALLINT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
dob DATE,
|
||||
workplace TEXT,
|
||||
title TEXT,
|
||||
qualification TEXT,
|
||||
contribution_percent NUMERIC(5,2) NOT NULL,
|
||||
is_representative BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
CHECK (contribution_percent >= 0 AND contribution_percent <= 100)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_authors_repr ON authors(initiative_id) WHERE is_representative;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS support_staff (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
full_name TEXT,
|
||||
dob DATE,
|
||||
workplace TEXT,
|
||||
title TEXT,
|
||||
qualification TEXT,
|
||||
support_content TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evidence_files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('textbook','research','technical')),
|
||||
storage_uri TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT 'application/pdf',
|
||||
byte_size BIGINT NOT NULL,
|
||||
sha256 CHAR(64) NOT NULL,
|
||||
uploaded_by UUID NOT NULL REFERENCES users(id),
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_kind ON evidence_files(initiative_id, kind);
|
||||
|
||||
-- ========= BÁO CÁO (REPORT) =========
|
||||
CREATE TABLE IF NOT EXISTS reports (
|
||||
initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
introduction TEXT,
|
||||
representative_phone TEXT,
|
||||
representative_email TEXT,
|
||||
current_status TEXT,
|
||||
purpose TEXT,
|
||||
implementation_steps TEXT,
|
||||
first_applied_unit TEXT,
|
||||
achieved_result TEXT,
|
||||
novelty TEXT,
|
||||
effectiveness JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
submission_date DATE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trial_units (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT,
|
||||
field TEXT,
|
||||
ordinal SMALLINT
|
||||
);
|
||||
|
||||
-- ========= CONTRIBUTION CONFIRMATION =========
|
||||
CREATE TABLE IF NOT EXISTS contributions (
|
||||
initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
main_author TEXT NOT NULL,
|
||||
position TEXT,
|
||||
representative_percent NUMERIC(5,2),
|
||||
submission_date TIMESTAMPTZ,
|
||||
digital_signature_confirmed BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contribution_participants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
full_name TEXT,
|
||||
work_unit TEXT,
|
||||
contribution_percent NUMERIC(5,2)
|
||||
);
|
||||
|
||||
-- ========= PHIẾU ĐÁNH GIÁ =========
|
||||
CREATE TABLE IF NOT EXISTS evaluations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
council_member_id UUID NOT NULL REFERENCES users(id),
|
||||
position TEXT,
|
||||
evaluation_date DATE NOT NULL,
|
||||
novelty_level eval_level,
|
||||
novelty_score SMALLINT,
|
||||
novelty_comment TEXT,
|
||||
effectiveness_level eval_level,
|
||||
effectiveness_score SMALLINT,
|
||||
effectiveness_comment TEXT,
|
||||
total_score SMALLINT GENERATED ALWAYS AS
|
||||
(COALESCE(novelty_score,0) + COALESCE(effectiveness_score,0)) STORED,
|
||||
conclusion TEXT,
|
||||
status submission_status NOT NULL DEFAULT 'draft',
|
||||
submitted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (novelty_score IS NULL OR (novelty_score BETWEEN 0 AND 40)),
|
||||
CHECK (effectiveness_score IS NULL OR (effectiveness_score BETWEEN 0 AND 60)),
|
||||
UNIQUE (initiative_id, council_member_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_eval_initiative ON evaluations(initiative_id);
|
||||
|
||||
-- ========= ADMIN VERIFY =========
|
||||
CREATE TABLE IF NOT EXISTS verifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
field_name TEXT NOT NULL,
|
||||
content_hash CHAR(64) NOT NULL,
|
||||
verified_by UUID NOT NULL REFERENCES users(id),
|
||||
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
result TEXT
|
||||
);
|
||||
|
||||
-- ========= AUDIT TRAIL =========
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
actor_id UUID REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
entity TEXT NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
diff JSONB,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity, entity_id);
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Versioned tab payloads + immutable submit snapshots + workflow/taxonomy + artifact registry.
|
||||
-- Apply on existing DBs: psql "$INITIATIVE_DATABASE_URL" -f migrations/002_application_storage_extensions.sql
|
||||
-- (use sync driver URL, not asyncpg, for psql)
|
||||
|
||||
-- ========= DRAFT TAB SNAPSHOTS (fe0: report | application | contribution) =========
|
||||
CREATE TABLE IF NOT EXISTS draft_tab_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
draft_id UUID REFERENCES drafts(id) ON DELETE SET NULL,
|
||||
tab TEXT NOT NULL CHECK (tab IN ('report', 'application', 'contribution')),
|
||||
tab_version INTEGER NOT NULL DEFAULT 1,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source TEXT NOT NULL DEFAULT 'autosave',
|
||||
captured_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_draft_tab_snapshots_init_tab_ver
|
||||
ON draft_tab_snapshots (initiative_id, tab, tab_version DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_draft_tab_snapshots_captured
|
||||
ON draft_tab_snapshots (captured_at DESC);
|
||||
|
||||
-- ========= SUBMIT SNAPSHOTS (immutable row per successful submit) =========
|
||||
CREATE TABLE IF NOT EXISTS application_submit_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
submission_record_id TEXT NOT NULL,
|
||||
merged_tabs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
submit_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
full_pdf_uri TEXT NOT NULL,
|
||||
captured_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_submit_snapshots_init_time
|
||||
ON application_submit_snapshots (initiative_id, captured_at DESC);
|
||||
|
||||
-- ========= WORKFLOW / LIST PROJECTION (council fields) =========
|
||||
CREATE TABLE IF NOT EXISTS application_workflow (
|
||||
initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
review_status TEXT NOT NULL DEFAULT 'not_reviewed',
|
||||
review_deadline DATE,
|
||||
reviewer JSONB,
|
||||
supervisor JSONB,
|
||||
conference JSONB,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ========= TAXONOMY (subjectId, groupId, topicType from fe0 ApplicationItem) =========
|
||||
CREATE TABLE IF NOT EXISTS application_taxonomy (
|
||||
initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
subject_id TEXT NOT NULL DEFAULT '',
|
||||
group_id TEXT NOT NULL DEFAULT '',
|
||||
topic_type TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ========= ARTIFACTS (PDF + future abstract/poster URIs; complements evidence_files) =========
|
||||
CREATE TABLE IF NOT EXISTS application_artifacts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN (
|
||||
'full_pdf', 'abstract', 'poster',
|
||||
'textbook_evidence', 'research_evidence', 'technical_evidence', 'other'
|
||||
)),
|
||||
storage_uri TEXT NOT NULL,
|
||||
original_name TEXT,
|
||||
mime_type TEXT NOT NULL DEFAULT 'application/pdf',
|
||||
byte_size BIGINT,
|
||||
sha256 CHAR(64),
|
||||
uploaded_by UUID REFERENCES users(id),
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (initiative_id, role)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_application_artifacts_init ON application_artifacts (initiative_id);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Persist ReviewPanel JSON bundles (templateData + officialBieuMau + full trees)
|
||||
-- Apply on existing DBs:
|
||||
-- psql "$INITIATIVE_DATABASE_URL" -f migrations/003_review_documents.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS application_review_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
case_id TEXT NOT NULL,
|
||||
document_version INTEGER NOT NULL DEFAULT 1,
|
||||
official_bieu_mau JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
template_data JSONB,
|
||||
full_bundle JSONB,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (initiative_id, document_version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_review_docs_initiative_time
|
||||
ON application_review_documents (initiative_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_docs_case_time
|
||||
ON application_review_documents (case_id, created_at DESC);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Admin-recorded adjudication outcome per initiative (linked to applicant application id API).
|
||||
-- One row per initiative; CRUD via /api/applications/{applicationId}/admin-result
|
||||
|
||||
CREATE TABLE IF NOT EXISTS application_admin_results (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
decision TEXT NOT NULL CHECK (decision IN ('approved','rejected')),
|
||||
feedback TEXT NOT NULL DEFAULT '',
|
||||
rationale TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
updated_by UUID REFERENCES users(id),
|
||||
CONSTRAINT uq_application_admin_results_initiative UNIQUE (initiative_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_application_admin_results_initiative
|
||||
ON application_admin_results(initiative_id);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Evidence staff review (approve / reject) on application_artifacts — must match be0/src/initiative_db/models.py ApplicationArtifact
|
||||
-- New DBs: loaded by docker-compose postgres init (04_...).
|
||||
-- Existing DBs: run once, e.g.
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/004_evidence_artifact_review.sql
|
||||
-- # or: psql "$INITIATIVE_DATABASE_URL" -f be0/migrations/004_evidence_artifact_review.sql
|
||||
|
||||
ALTER TABLE application_artifacts
|
||||
ADD COLUMN IF NOT EXISTS review_status TEXT,
|
||||
ADD COLUMN IF NOT EXISTS reviewed_by UUID REFERENCES users (id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_application_artifacts_review
|
||||
ON application_artifacts (initiative_id, review_status);
|
||||
@@ -0,0 +1,26 @@
|
||||
-- In-app notifications for applicants (admin adjudication → inbox).
|
||||
-- Best-effort insert after PUT/POST admin-result; full text duplicated for read UX.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
recipient_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL CHECK (type IN ('admin_application_decision')),
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
application_id TEXT NOT NULL,
|
||||
related_initiative_id UUID REFERENCES initiatives(id) ON DELETE SET NULL,
|
||||
source_admin_result_id UUID REFERENCES application_admin_results(id) ON DELETE SET NULL,
|
||||
decision TEXT NOT NULL CHECK (decision IN ('approved','rejected')),
|
||||
merit_category_label TEXT,
|
||||
feedback_text TEXT NOT NULL DEFAULT '',
|
||||
rationale_text TEXT,
|
||||
read_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_notifications_inbox_idx
|
||||
ON user_notifications (recipient_user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_notifications_unread_idx
|
||||
ON user_notifications (recipient_user_id)
|
||||
WHERE read_at IS NULL;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Policy-sourced admin rows: safe to drop when email leaves AUTH_ADMIN_EMAILS (app reconciliation).
|
||||
-- Apply on existing DBs: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/007_user_roles_email_policy_admin.sql
|
||||
-- Fresh docker-compose init: add this file as docker-entrypoint-initdb.d/07_*.sql
|
||||
|
||||
ALTER TABLE user_roles ADD COLUMN IF NOT EXISTS admin_from_email_policy BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
COMMENT ON COLUMN user_roles.admin_from_email_policy IS
|
||||
'TRUE when admin was granted by email allow-list (AUTH_ADMIN_EMAILS). Reconciliation may DELETE this row if the user email is no longer in the list. FALSE preserves manually granted admin (future / exceptional).';
|
||||
|
||||
-- One-time cleanup: remove admin for addresses not in the default institutional allow-list
|
||||
-- (must match default in auth_api._DEFAULT_POLICY_ADMIN_EMAILS when AUTH_ADMIN_EMAILS is unset).
|
||||
DELETE FROM user_roles ur
|
||||
USING users u
|
||||
WHERE ur.user_id = u.id
|
||||
AND ur.role::text = 'admin'
|
||||
AND lower(u.email::text) NOT IN (
|
||||
'thaontt@ump.edu.vn',
|
||||
'nltanh@ump.edu.vn',
|
||||
'ldbaochau@ump.edu.vn',
|
||||
'htchuong@ump.edu.vn'
|
||||
);
|
||||
|
||||
UPDATE user_roles ur
|
||||
SET admin_from_email_policy = TRUE
|
||||
FROM users u
|
||||
WHERE ur.user_id = u.id
|
||||
AND ur.role::text = 'admin'
|
||||
AND lower(u.email::text) IN (
|
||||
'thaontt@ump.edu.vn',
|
||||
'nltanh@ump.edu.vn',
|
||||
'ldbaochau@ump.edu.vn',
|
||||
'htchuong@ump.edu.vn'
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Unified append-only audit trail (see assets/docs/audit-log-implementation.md).
|
||||
-- Application role should be granted INSERT, SELECT only (configure per deployment).
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE TYPE audit_action AS ENUM (
|
||||
'create',
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
'login',
|
||||
'logout',
|
||||
'login_failed'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_email TEXT NOT NULL,
|
||||
actor_role TEXT NOT NULL,
|
||||
action audit_action NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT,
|
||||
before JSONB,
|
||||
after JSONB,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
request_id UUID
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor_time ON audit_events (actor_user_id, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_events (entity_type, entity_id, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action_time ON audit_events (action, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_metadata_gin ON audit_events USING gin (metadata);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Backup / canonical storage: official printable DOCX+PDF roles + explicit storage_kind.
|
||||
-- Apply: psql "$INITIATIVE_DATABASE_URL" -f migrations/009_backup_artifact_roles_storage_kind.sql
|
||||
|
||||
ALTER TABLE application_artifacts DROP CONSTRAINT IF EXISTS application_artifacts_role_check;
|
||||
ALTER TABLE application_artifacts ADD CONSTRAINT application_artifacts_role_check CHECK (role IN (
|
||||
'full_pdf',
|
||||
'abstract',
|
||||
'poster',
|
||||
'textbook_evidence',
|
||||
'research_evidence',
|
||||
'technical_evidence',
|
||||
'other',
|
||||
'official_form_docx',
|
||||
'official_form_pdf'
|
||||
));
|
||||
|
||||
ALTER TABLE application_artifacts
|
||||
ADD COLUMN IF NOT EXISTS storage_kind TEXT;
|
||||
|
||||
UPDATE application_artifacts SET storage_kind = CASE
|
||||
WHEN storage_uri LIKE 'http://%' OR storage_uri LIKE 'https://%' THEN 'external_url'
|
||||
WHEN storage_uri LIKE '/submitted-initiatives/%' THEN 'filesystem'
|
||||
WHEN role IN ('research_evidence', 'textbook_evidence', 'technical_evidence') THEN 'minio_attachments'
|
||||
ELSE 'minio_exports'
|
||||
END
|
||||
WHERE storage_kind IS NULL;
|
||||
|
||||
ALTER TABLE application_artifacts DROP CONSTRAINT IF EXISTS application_artifacts_storage_kind_check;
|
||||
ALTER TABLE application_artifacts ADD CONSTRAINT application_artifacts_storage_kind_check
|
||||
CHECK (storage_kind IS NULL OR storage_kind IN (
|
||||
'minio_exports',
|
||||
'minio_attachments',
|
||||
'filesystem',
|
||||
'external_url'
|
||||
));
|
||||
@@ -0,0 +1,114 @@
|
||||
-- User staff profiles (1:1 with users) — HR / verification workflow
|
||||
-- Apply: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/010_user_staff_profiles.sql
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE profile_verification_status AS ENUM ('draft', 'pending', 'verified', 'rejected');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academic_titles (
|
||||
code TEXT PRIMARY KEY,
|
||||
label_vi TEXT NOT NULL,
|
||||
label_en TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
INSERT INTO academic_titles (code, label_vi, label_en, sort_order) VALUES
|
||||
('professor', 'Giáo sư', 'Professor', 10),
|
||||
('associate_professor', 'Phó Giáo sư', 'Associate Professor', 20),
|
||||
('doctor_sc', 'Tiến sĩ', 'Doctor of Science', 30),
|
||||
('bsckii', 'BSCKII', 'Specialist level II', 35),
|
||||
('bscki', 'BSCKI', 'Specialist level I', 36),
|
||||
('master', 'Thạc sĩ', 'Master', 40),
|
||||
('doctor_md', 'Bác sĩ', 'Physician', 45),
|
||||
('pharmacist', 'Dược sĩ', 'Pharmacist', 46),
|
||||
('bachelor', 'Cử nhân', 'Bachelor', 50),
|
||||
('other', 'Khác (ghi rõ)', 'Other (specify)', 100)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_staff_profiles (
|
||||
user_id UUID PRIMARY KEY
|
||||
REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
employee_id TEXT,
|
||||
academic_title_code TEXT REFERENCES academic_titles(code),
|
||||
academic_title_other TEXT,
|
||||
unit_name_freetext TEXT,
|
||||
job_title TEXT,
|
||||
|
||||
profile_verification_status profile_verification_status
|
||||
NOT NULL DEFAULT 'draft',
|
||||
verification_submitted_at TIMESTAMPTZ,
|
||||
verified_at TIMESTAMPTZ,
|
||||
verified_by_user_id UUID REFERENCES users(id),
|
||||
rejection_reason TEXT,
|
||||
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT employee_id_shape
|
||||
CHECK (employee_id IS NULL OR employee_id ~ '^[A-Z0-9-]{3,32}$'),
|
||||
|
||||
CONSTRAINT academic_title_other_invariant CHECK (
|
||||
CASE
|
||||
WHEN academic_title_code IS NULL THEN academic_title_other IS NULL
|
||||
WHEN academic_title_code = 'other' THEN
|
||||
academic_title_other IS NOT NULL AND length(trim(academic_title_other)) > 0
|
||||
ELSE academic_title_other IS NULL
|
||||
END
|
||||
),
|
||||
|
||||
CONSTRAINT verified_requires_metadata CHECK (
|
||||
profile_verification_status <> 'verified'
|
||||
OR (verified_at IS NOT NULL AND verified_by_user_id IS NOT NULL)
|
||||
),
|
||||
|
||||
CONSTRAINT rejected_requires_reason CHECK (
|
||||
profile_verification_status <> 'rejected'
|
||||
OR (rejection_reason IS NOT NULL AND length(trim(rejection_reason)) > 0)
|
||||
),
|
||||
|
||||
CONSTRAINT non_terminal_clears_verification CHECK (
|
||||
profile_verification_status NOT IN ('draft', 'pending')
|
||||
OR (verified_at IS NULL AND verified_by_user_id IS NULL)
|
||||
),
|
||||
|
||||
CONSTRAINT rejected_clears_verification_metadata CHECK (
|
||||
profile_verification_status <> 'rejected'
|
||||
OR (verified_at IS NULL AND verified_by_user_id IS NULL)
|
||||
),
|
||||
|
||||
CONSTRAINT verified_clears_rejection CHECK (
|
||||
profile_verification_status <> 'verified'
|
||||
OR rejection_reason IS NULL
|
||||
),
|
||||
|
||||
CONSTRAINT job_title_length CHECK (
|
||||
job_title IS NULL OR length(job_title) <= 120
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ix_usp_employee_id_unique
|
||||
ON user_staff_profiles (employee_id)
|
||||
WHERE employee_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_usp_pending_queue
|
||||
ON user_staff_profiles (verification_submitted_at)
|
||||
WHERE profile_verification_status = 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_usp_verifier_activity
|
||||
ON user_staff_profiles (verified_by_user_id, verified_at DESC)
|
||||
WHERE verified_by_user_id IS NOT NULL;
|
||||
|
||||
-- Backfill one row per existing user (draft, NULL fields)
|
||||
INSERT INTO user_staff_profiles (user_id, profile_verification_status)
|
||||
SELECT u.id, 'draft'::profile_verification_status
|
||||
FROM users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM user_staff_profiles p WHERE p.user_id = u.id
|
||||
);
|
||||
|
||||
COMMENT ON TABLE user_staff_profiles IS
|
||||
'Institutional staff profile and verification state; scalars only — no MinIO.';
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Extend / refresh academic_titles for UMP staff profile dropdown (VN labels + BSCK codes).
|
||||
-- Apply after 010: psql … -f be0/migrations/011_academic_titles_vn.sql
|
||||
|
||||
INSERT INTO academic_titles (code, label_vi, label_en, sort_order, active) VALUES
|
||||
('professor', 'Giáo sư', 'Professor', 10, TRUE),
|
||||
('associate_professor', 'Phó Giáo sư', 'Associate Professor', 20, TRUE),
|
||||
('doctor_sc', 'Tiến sĩ', 'Doctor of Science', 30, TRUE),
|
||||
('bsckii', 'BSCKII', 'Specialist level II', 35, TRUE),
|
||||
('bscki', 'BSCKI', 'Specialist level I', 36, TRUE),
|
||||
('master', 'Thạc sĩ', 'Master', 40, TRUE),
|
||||
('doctor_md', 'Bác sĩ', 'Physician', 45, TRUE),
|
||||
('pharmacist', 'Dược sĩ', 'Pharmacist', 46, TRUE),
|
||||
('bachelor', 'Cử nhân', 'Bachelor', 50, TRUE),
|
||||
('other', 'Khác (ghi rõ)', 'Other (specify)', 100, TRUE)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
label_vi = EXCLUDED.label_vi,
|
||||
label_en = EXCLUDED.label_en,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
active = EXCLUDED.active;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Password reset tokens + JWT credential invalidation (see auth_api, auth_credential_middleware).
|
||||
-- Apply: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/012_password_reset.sql
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS credential_version INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN users.credential_version IS
|
||||
'Incremented on password change/reset. JWT ''cv'' claim must match or token is rejected.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at ON password_reset_tokens(expires_at);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Email verification before login (see auth_api deliver_email_verification_email).
|
||||
-- Apply: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/013_email_verification.sql
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
UPDATE users SET email_verified = TRUE WHERE email_verified = FALSE;
|
||||
|
||||
COMMENT ON COLUMN users.email_verified IS
|
||||
'FALSE until user confirms institutional inbox via email link; login and API tokens require TRUE.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user_id ON email_verification_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens(expires_at);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Registration email verification via 6-digit OTP (replaces magic-link issuance on register).
|
||||
-- Apply after 013_email_verification.sql:
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/014_registration_otp.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS registration_otp_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
otp_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
failed_attempts INT NOT NULL DEFAULT 0,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_registration_otp_codes_user_pending
|
||||
ON registration_otp_codes (user_id)
|
||||
WHERE used_at IS NULL;
|
||||
|
||||
COMMENT ON TABLE registration_otp_codes IS
|
||||
'Hashed 6-digit OTP for register verification; pending rows deleted when superseded by resend.';
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Admin-managed document templates: a .docx (stored in MinIO bucket initiative-templates)
|
||||
-- plus its extracted Jinja placeholder fields. Applicants render a filled PDF by template id.
|
||||
-- Apply after 014_registration_otp.sql:
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/015_document_templates.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
storage_key TEXT NOT NULL,
|
||||
original_filename TEXT,
|
||||
content_sha256 TEXT,
|
||||
fields JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_document_templates_active
|
||||
ON document_templates (is_active, created_at DESC);
|
||||
|
||||
COMMENT ON TABLE document_templates IS
|
||||
'Admin-managed DOCX templates (file in MinIO initiative-templates) with extracted Jinja placeholder fields. Applicants render filled PDFs by template id.';
|
||||
@@ -0,0 +1,133 @@
|
||||
-- Research-project proposals (Thuyết minh đề tài, Mẫu III.06-TM.ĐTUD) + the PI "cockpit" entities.
|
||||
-- A proposal row IS the project across its lifecycle: draft -> submitted -> approved | rejected.
|
||||
-- On approval the cockpit unlocks; child tables (members/datasets/models/assets/milestones) hang off it.
|
||||
-- Owner+admin authz (v1): a project is owned by owner_user_id; admins may review/approve/reject.
|
||||
-- Apply after 015_document_templates.sql:
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/016_research_projects.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS research_projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','submitted','approved','rejected')),
|
||||
code TEXT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
level TEXT NOT NULL DEFAULT '',
|
||||
pi_name TEXT NOT NULL DEFAULT '',
|
||||
period_months INTEGER,
|
||||
budget_total NUMERIC(14,2),
|
||||
content JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
submitted_at TIMESTAMPTZ,
|
||||
reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
review_note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_research_projects_owner ON research_projects (owner_user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_research_projects_status ON research_projects (status, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS research_project_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
access TEXT NOT NULL DEFAULT '',
|
||||
org TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
months INTEGER,
|
||||
tasks TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_research_project_members_project ON research_project_members (project_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS research_project_datasets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
records INTEGER,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
sensitivity TEXT NOT NULL DEFAULT '',
|
||||
ethics TEXT NOT NULL DEFAULT '',
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_research_project_datasets_project ON research_project_datasets (project_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS research_project_models (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
task TEXT NOT NULL DEFAULT '',
|
||||
framework TEXT NOT NULL DEFAULT '',
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
dataset TEXT NOT NULL DEFAULT '',
|
||||
auc NUMERIC(6,4),
|
||||
sensitivity NUMERIC(6,4),
|
||||
specificity NUMERIC(6,4),
|
||||
accuracy NUMERIC(6,4),
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_research_project_models_project ON research_project_models (project_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS research_project_assets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
acquisition TEXT NOT NULL DEFAULT '',
|
||||
value NUMERIC(14,2),
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_research_project_assets_project ON research_project_assets (project_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS research_project_milestones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
deliverable TEXT NOT NULL DEFAULT '',
|
||||
start_period TEXT NOT NULL DEFAULT '',
|
||||
end_period TEXT NOT NULL DEFAULT '',
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
budget NUMERIC(14,2),
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_research_project_milestones_project ON research_project_milestones (project_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS research_project_audit (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_name TEXT NOT NULL DEFAULT '',
|
||||
role_label TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_research_project_audit_project ON research_project_audit (project_id, occurred_at DESC);
|
||||
|
||||
COMMENT ON TABLE research_projects IS
|
||||
'Research-project proposals (Thuyet minh de tai) that become managed projects on approval. Owner and admin authz. Content JSONB holds the full proposal form. Child research_project_* tables hold cockpit entities.';
|
||||
@@ -0,0 +1,76 @@
|
||||
-- ImageHub: content-addressed imaging dataset versioning (milestone 1 walking skeleton).
|
||||
-- A dataset is owned by a user (investigator/PI). Files are stored as content-addressed,
|
||||
-- globally deduped blobs in MinIO (one imagehub_blobs row per distinct sha256). The current
|
||||
-- working file set lives in imagehub_dataset_files; a version freezes a manifest snapshot.
|
||||
-- Admin sees all datasets (clinical data repository); owners see their own (research data).
|
||||
-- Apply after 016_research_projects.sql:
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/017_imagehub_datasets.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imagehub_datasets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
slug TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private','internal','public')),
|
||||
modality_tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
default_branch TEXT NOT NULL DEFAULT 'main',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_datasets_owner ON imagehub_datasets (owner_user_id, created_at DESC);
|
||||
|
||||
-- Globally content-addressed blob registry: identical bytes across datasets dedupe to one row.
|
||||
CREATE TABLE IF NOT EXISTS imagehub_blobs (
|
||||
sha256 TEXT PRIMARY KEY,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
media_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
storage_bucket TEXT NOT NULL DEFAULT '',
|
||||
storage_key TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Current working file set on a dataset default branch (one row per logical path).
|
||||
CREATE TABLE IF NOT EXISTS imagehub_dataset_files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
|
||||
logical_path TEXT NOT NULL DEFAULT '',
|
||||
blob_sha256 TEXT NOT NULL REFERENCES imagehub_blobs(sha256) ON DELETE RESTRICT,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
media_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
imaging_meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_dataset_files_path ON imagehub_dataset_files (dataset_id, logical_path);
|
||||
|
||||
-- Frozen version snapshots (the versioning spine; DAG-ready via parent_version_id).
|
||||
CREATE TABLE IF NOT EXISTS imagehub_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL DEFAULT 1,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
manifest JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
parent_version_id UUID REFERENCES imagehub_versions(id) ON DELETE SET NULL,
|
||||
author_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_versions_seq ON imagehub_versions (dataset_id, seq);
|
||||
|
||||
-- Append-only audit trail per dataset.
|
||||
CREATE TABLE IF NOT EXISTS imagehub_dataset_audit (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_name TEXT NOT NULL DEFAULT '',
|
||||
role_label TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_audit_dataset ON imagehub_dataset_audit (dataset_id, occurred_at DESC);
|
||||
|
||||
COMMENT ON TABLE imagehub_datasets IS
|
||||
'ImageHub content-addressed imaging datasets. Owner and admin authz. Files dedupe into imagehub_blobs by sha256 — imagehub_versions freezes a manifest snapshot.';
|
||||
@@ -0,0 +1,21 @@
|
||||
-- ImageHub: link organ-segmentation masks to their parent image file (Phase D).
|
||||
-- A mask file (file_kind='segmentation') points at the image it segments via a
|
||||
-- self-referential parent_file_id (e.g. an organ mask of ct.nii.gz); organ_label
|
||||
-- names the organ. Regular files stay file_kind='image'. Idempotent (ADD COLUMN IF
|
||||
-- NOT EXISTS) so the startup runner can apply it to volumes that predate it.
|
||||
-- Apply after 017_imagehub_datasets.sql (no semicolons inside comments — the runner
|
||||
-- splitter is naive):
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/018_imagehub_segmentation_links.sql
|
||||
|
||||
ALTER TABLE imagehub_dataset_files
|
||||
ADD COLUMN IF NOT EXISTS file_kind TEXT NOT NULL DEFAULT 'image' CHECK (file_kind IN ('image','segmentation'));
|
||||
|
||||
ALTER TABLE imagehub_dataset_files
|
||||
ADD COLUMN IF NOT EXISTS parent_file_id UUID REFERENCES imagehub_dataset_files(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE imagehub_dataset_files
|
||||
ADD COLUMN IF NOT EXISTS organ_label TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- List all masks of an image efficiently.
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_files_parent
|
||||
ON imagehub_dataset_files (parent_file_id);
|
||||
@@ -0,0 +1,53 @@
|
||||
-- ImageHub: Cloud Import — storage methods + external (referenced, not copied) dataset files.
|
||||
-- A storage method holds verified credentials (config_encrypted, never returned to the client)
|
||||
-- for an external bucket (S3/GCS/Azure). A dataset file is then EITHER a local content-addressed
|
||||
-- blob (blob_sha256 set) OR an external reference (storage_method_id + external_path set) that
|
||||
-- streams from the bucket and is never copied to our servers (privacy rule C4). Idempotent
|
||||
-- (CREATE/ADD ... IF NOT EXISTS) so the startup runner can apply it to volumes that predate it.
|
||||
-- Apply after 018 (no semicolons inside comments or string literals — the runner splitter is naive):
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/019_imagehub_cloud_import.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imagehub_storage_methods (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL CHECK (provider IN ('s3','gcs','azure')),
|
||||
access_mode TEXT NOT NULL DEFAULT 'read' CHECK (access_mode IN ('read','readwrite')),
|
||||
bucket TEXT NOT NULL,
|
||||
region TEXT,
|
||||
config_encrypted TEXT NOT NULL,
|
||||
verification_status TEXT NOT NULL DEFAULT 'pending' CHECK (verification_status IN ('pending','verified','failed')),
|
||||
verification_reason TEXT,
|
||||
verification_checked_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_storage_methods_owner
|
||||
ON imagehub_storage_methods (owner_id);
|
||||
|
||||
-- Allow a dataset file to be an external reference instead of a local blob. Existing rows keep
|
||||
-- blob_sha256 set and the new columns NULL, so they satisfy the local-blob branch of the CHECK.
|
||||
ALTER TABLE imagehub_dataset_files
|
||||
ALTER COLUMN blob_sha256 DROP NOT NULL;
|
||||
|
||||
ALTER TABLE imagehub_dataset_files
|
||||
ADD COLUMN IF NOT EXISTS storage_method_id UUID REFERENCES imagehub_storage_methods(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE imagehub_dataset_files
|
||||
ADD COLUMN IF NOT EXISTS external_path TEXT;
|
||||
|
||||
-- A file is EITHER a local content-addressed blob OR an external reference, never both or neither.
|
||||
ALTER TABLE imagehub_dataset_files
|
||||
DROP CONSTRAINT IF EXISTS ck_imagehub_file_storage_mode;
|
||||
|
||||
ALTER TABLE imagehub_dataset_files
|
||||
ADD CONSTRAINT ck_imagehub_file_storage_mode CHECK (
|
||||
(blob_sha256 IS NOT NULL AND storage_method_id IS NULL AND external_path IS NULL)
|
||||
OR
|
||||
(blob_sha256 IS NULL AND storage_method_id IS NOT NULL AND external_path IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_files_storage_method
|
||||
ON imagehub_dataset_files (storage_method_id);
|
||||
@@ -0,0 +1,26 @@
|
||||
-- ImageHub: labeling-pipeline stages on a dataset (Label -> Review_1 -> Review_2 ...). Each stage
|
||||
-- has a kind (label/review), an order (seq), an optional review_percent (review stages only), and
|
||||
-- an auto_assign flag (the "Automatic Task Assignment" toggle). Idempotent (CREATE ... IF NOT
|
||||
-- EXISTS) so the startup runner can apply it to volumes that predate it. Apply after 019 (no
|
||||
-- semicolons inside comments or string literals — the runner splitter is naive):
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/020_imagehub_dataset_stages.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imagehub_dataset_stages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'label' CHECK (kind IN ('label','review')),
|
||||
seq INTEGER NOT NULL DEFAULT 0,
|
||||
review_percent INTEGER CHECK (review_percent IS NULL OR (review_percent >= 0 AND review_percent <= 100)),
|
||||
auto_assign BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Stages of a dataset, in pipeline order.
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_stages_dataset
|
||||
ON imagehub_dataset_stages (dataset_id, seq);
|
||||
|
||||
-- A stage name is unique within its dataset.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_dataset_stages_name
|
||||
ON imagehub_dataset_stages (dataset_id, name);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- ImageHub: per-file work TASKS that flow through a dataset's pipeline stages (single-user MVP).
|
||||
-- A task is a NEW join row (one per dataset file) carrying its pipeline position (current_stage_id
|
||||
-- + pipeline_state), per-user queue status, assignee, priority, and the Ground-Truth reference flag.
|
||||
-- The file row itself (imagehub_dataset_files) stays a pure storage record. Membership / multi-labeler
|
||||
-- assignment is a later phase, so for now task access reuses the dataset owner-or-admin gate.
|
||||
-- Idempotent (CREATE ... IF NOT EXISTS) so the startup runner can apply it to volumes that predate it.
|
||||
-- Apply after 020 (no semicolons inside comments or string literals — the runner splitter is naive):
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/021_imagehub_task_pipeline.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imagehub_tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
|
||||
dataset_file_id UUID NOT NULL REFERENCES imagehub_dataset_files(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
current_stage_id UUID REFERENCES imagehub_dataset_stages(id) ON DELETE SET NULL,
|
||||
pipeline_state TEXT NOT NULL DEFAULT 'inLabel' CHECK (pipeline_state IN ('inLabel','inReview','groundTruth','issue')),
|
||||
queue_status TEXT NOT NULL DEFAULT 'assigned' CHECK (queue_status IN ('assigned','saved','pendingFinalization','skipped')),
|
||||
assignee_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
assignment_mode TEXT NOT NULL DEFAULT 'auto' CHECK (assignment_mode IN ('auto','manual')),
|
||||
priority DOUBLE PRECISION NOT NULL DEFAULT 0 CHECK (priority >= 0 AND priority <= 1),
|
||||
is_reference_standard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
skipped_seq BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- One task per file (MVP simplification — droppable later for multi-task-per-file).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_tasks_file
|
||||
ON imagehub_tasks (dataset_file_id);
|
||||
|
||||
-- Queue scan: a dataset's tasks at a given stage and status, highest priority first.
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_tasks_queue
|
||||
ON imagehub_tasks (dataset_id, current_stage_id, queue_status, priority DESC);
|
||||
|
||||
-- A user's personal labeling queue across datasets.
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_tasks_assignee
|
||||
ON imagehub_tasks (assignee_user_id, queue_status);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- ImageHub: a task's labeler annotations (bbox / points / pen / brush / polygon) stored as JSON.
|
||||
-- The shared viewer's annotation overlay emits normalized [0..1] vector geometry per slice — small
|
||||
-- JSON, persisted on the task so the AnnotationTool can load + save a labeler's work. Idempotent
|
||||
-- (ADD COLUMN IF NOT EXISTS) so the startup runner can apply it to volumes that predate it. Apply
|
||||
-- after 021 (no semicolons inside comments or string literals — the runner splitter is naive):
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/022_imagehub_task_annotations.sql
|
||||
|
||||
ALTER TABLE imagehub_tasks ADD COLUMN IF NOT EXISTS annotations JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- ImageHub: dataset membership — lets users other than the owner work a dataset's tasks
|
||||
-- (multi-labeler). MVP treats all members as labelers: they view the dataset and work tasks
|
||||
-- assigned to them, while dataset / stage / settings management stays with the owner + platform
|
||||
-- admins. The role column is reserved for a future project-admin tier. Idempotent. Apply after 022
|
||||
-- (no semicolons inside comments or string literals — the runner splitter is naive):
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/023_imagehub_dataset_members.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imagehub_dataset_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('project_admin','member')),
|
||||
added_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- One membership per user per dataset.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_dataset_members_user
|
||||
ON imagehub_dataset_members (dataset_id, user_id);
|
||||
|
||||
-- "Datasets I am a member of" lookup (the member's dataset list).
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_members_user
|
||||
ON imagehub_dataset_members (user_id);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- ImageHub: link a dataset to a research project (the "workspace" superstructure). Nullable,
|
||||
-- so existing datasets stay unlinked and a dataset can still exist standalone. A dataset created
|
||||
-- from a project cockpit attaches to that project. ON DELETE SET NULL so deleting a project
|
||||
-- orphans its datasets rather than dropping the imaging data. Idempotent. Apply after 023
|
||||
-- (no semicolons inside comments or string literals — the runner splitter is naive):
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/024_imagehub_dataset_project_link.sql
|
||||
|
||||
ALTER TABLE imagehub_datasets
|
||||
ADD COLUMN IF NOT EXISTS research_project_id UUID REFERENCES research_projects(id) ON DELETE SET NULL;
|
||||
|
||||
-- "Datasets in this project" lookup (the project-scoped dataset list).
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_datasets_research_project
|
||||
ON imagehub_datasets (research_project_id);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- ImageHub: structured review decisions. The task pipeline applies accept/acceptWithCorrections/
|
||||
-- reject moves, but until now the verdict survived only as a free-text Vietnamese audit string —
|
||||
-- not queryable, no reviewer/stage FK, no reject reason. This append-only table records every
|
||||
-- review decision so review history + per-reviewer accept/reject counters become real. Idempotent.
|
||||
-- Apply after 024 (no semicolons inside comments or string literals — the runner splitter is naive):
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/025_imagehub_task_review_events.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imagehub_task_review_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
|
||||
task_id UUID NOT NULL REFERENCES imagehub_tasks(id) ON DELETE CASCADE,
|
||||
stage_id UUID REFERENCES imagehub_dataset_stages(id) ON DELETE SET NULL,
|
||||
reviewer_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
decision TEXT NOT NULL CHECK (decision IN ('accept','acceptWithCorrections','reject')),
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Per-reviewer counters over a date window (the productivity panel query).
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_review_events_reviewer
|
||||
ON imagehub_task_review_events (dataset_id, reviewer_user_id, created_at);
|
||||
|
||||
-- A task's review history (chronological).
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_review_events_task
|
||||
ON imagehub_task_review_events (task_id, created_at);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- ImageHub: persist the relative folder path of each uploaded file (Option B — real folders inside
|
||||
-- a dataset). Until now logical_path was basename-flattened, so an uploaded directory structure
|
||||
-- (e.g. the nnU-Net imagesTr/labelsTr layout) was lost once files reached MinIO. folder_path keeps
|
||||
-- the relative directory so the dataset browser can render a real folder tree and the structure
|
||||
-- round-trips. The working-file natural key moves from (dataset_id, logical_path) to
|
||||
-- (dataset_id, folder_path, logical_path) so two files sharing a basename in different folders no
|
||||
-- longer collide and silently merge. Existing rows default folder_path to the empty string, so the
|
||||
-- new key stays unique wherever the old one was. Idempotent.
|
||||
-- Apply after 025 (no semicolons inside comments or string literals — the runner splitter is naive):
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/026_imagehub_file_folder_path.sql
|
||||
|
||||
ALTER TABLE imagehub_dataset_files
|
||||
ADD COLUMN IF NOT EXISTS folder_path TEXT NOT NULL DEFAULT '';
|
||||
|
||||
DROP INDEX IF EXISTS uq_imagehub_dataset_files_path;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_dataset_files_folder_path
|
||||
ON imagehub_dataset_files (dataset_id, folder_path, logical_path);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_files_folder
|
||||
ON imagehub_dataset_files (dataset_id, folder_path);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- ImageHub: per-dataset value to name label map for multi-label segmentation masks. A multi-label
|
||||
-- labelsTr/<case>.nii.gz encodes each organ or structure as an integer voxel value (1, 2, 3 …). Until
|
||||
-- now the viewer named those values from a fixed TotalSegmentator-v2 117-class map, so a non
|
||||
-- TotalSegmentator dataset (KiTS = 1 kidney / 2 tumor / 3 cyst, or any custom nnU-Net labels) showed
|
||||
-- confidently-wrong organ names. label_map stores the dataset own value to name mapping (a JSON object
|
||||
-- with string keys), so the organ panel labels each overlay correctly and a user can edit them. The
|
||||
-- empty default keeps the TotalSegmentator fallback for datasets without a map. Idempotent.
|
||||
-- Apply after 026 (no semicolons inside comments or string literals — the runner splitter is naive):
|
||||
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/027_imagehub_dataset_label_map.sql
|
||||
|
||||
ALTER TABLE imagehub_datasets
|
||||
ADD COLUMN IF NOT EXISTS label_map JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
Reference in New Issue
Block a user