sciagent code + Gitea Actions CI/CD
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
+251
View File
@@ -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);
+22
View File
@@ -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);
+26
View File
@@ -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'
);
+38
View File
@@ -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'
));
+114
View File
@@ -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.';
+19
View File
@@ -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;
+19
View File
@@ -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);
+21
View File
@@ -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);
+20
View File
@@ -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.';
+24
View File
@@ -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.';
+133
View File
@@ -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.';
+76
View File
@@ -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;