sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# Build context for the frontend_user / frontend_admin images is the repo ROOT (the
|
||||
# npm workspace). Keep only root manifests + shared/ + the two app dirs; exclude the
|
||||
# rest so the context stays small. (be0 builds from ./be0 and is unaffected by this file.)
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/dist-ssr
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
.claude
|
||||
docs
|
||||
be0
|
||||
fe0
|
||||
assets
|
||||
database
|
||||
Posgresdb
|
||||
scripts
|
||||
deploy
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
**/__pycache__
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# ============================================================
|
||||
# Production / docker-compose.prod.yml
|
||||
# -----------------------------------------------------------
|
||||
# 1. Copy: cp .env.example .env
|
||||
# 2. Fill every value below (never commit .env — it is gitignored).
|
||||
# 3. Prefer strong random secrets:
|
||||
# openssl rand -base64 32
|
||||
#
|
||||
# Before deploy: ./scripts/verify-prod-env.sh
|
||||
# Full deploy: ./scripts/deploy-prod.sh
|
||||
# Stack map (FE→BE→DB→MinIO): docs/deploy-stack-overview.md
|
||||
# Postgres / volume quirks: docs/deploy-production-docker.md
|
||||
#
|
||||
# If .env was ever committed to git, rotate ALL secrets below.
|
||||
# ============================================================
|
||||
|
||||
# Public hostname or IP that browsers use to reach this machine.
|
||||
PUBLIC_HOST=your-public-hostname-or-ip.example.com
|
||||
|
||||
FE_PORT=8081
|
||||
|
||||
# Optional: admin/council SPA port. Bound to 127.0.0.1 only in docker-compose.prod.yml
|
||||
# (reach it via SSH tunnel or an authenticated reverse-proxy vhost). Defaults to 8082.
|
||||
# FE_ADMIN_PORT=8082
|
||||
|
||||
# Optional: principal-investigator SPA port (research proposals + project cockpit). Defaults to 8083.
|
||||
# FE_INV_PORT=8083
|
||||
|
||||
# Optional: publisher SPA port (research-result publication). Defaults to 8084.
|
||||
# FE_PUB_PORT=8084
|
||||
|
||||
# Optional: extra CORS Allowed-Origins for be0 (comma-separated, no spaces). Production compose sets
|
||||
# CORS_ORIGINS to http://${PUBLIC_HOST}:${FE_PORT} plus these extras automatically.
|
||||
# CORS_ORIGINS_EXTRA=https://app.example.com,http://internal:8081
|
||||
|
||||
MINIO_API_PORT=19000
|
||||
MINIO_CONSOLE_PORT=19001
|
||||
|
||||
MINIO_ROOT_USER=minio_root_change_me
|
||||
MINIO_ROOT_PASSWORD=replace_with_long_random_secret
|
||||
|
||||
# --- HTTPS for MinIO presigned URLs (required if the SPA is https://…) ------------
|
||||
# Mixed content blocks http://PUBLIC_HOST:19000 embedded from an HTTPS UI. Options:
|
||||
# A) Proxied viewer only (already in-app) — no change needed for preview.
|
||||
# B) HTTPS for direct MinIO links (iframe / “open presigned URL”) — put TLS in front
|
||||
# of the S3 API port and align these with that public URL. See docs/minio-behind-https.md .
|
||||
# Example subdomain (recommended):
|
||||
# S3_PUBLIC_ENDPOINT_URL=https://minio-api.your-domain.com
|
||||
# MINIO_SERVER_URL=https://minio-api.your-domain.com
|
||||
# Optionally point the console at HTTPS too:
|
||||
# MINIO_BROWSER_REDIRECT_URL=https://minio-console.your-domain.com
|
||||
# If omitted, Compose keeps using http://${PUBLIC_HOST}:${MINIO_API_PORT} for both.
|
||||
|
||||
# Username + password are fixed the first time the Postgres volume is created (see comment below).
|
||||
|
||||
# Identifier only (letters, digits, underscore) — avoids URL / healthcheck pitfalls.
|
||||
POSTGRES_USER=postgres_app_user
|
||||
POSTGRES_PASSWORD=replace_with_long_random_secret
|
||||
|
||||
# Optional: only for scripts/sync-postgres-app-password.sh when the app role is not superuser
|
||||
# or you must connect as a different DB superuser (e.g. postgres) to run ALTER ROLE.
|
||||
# POSTGRES_SUPERUSER=postgres
|
||||
|
||||
# Database name created on first init (normally keep "initiatives").
|
||||
POSTGRES_DB=initiatives
|
||||
|
||||
# --- Auth (required for production) ------------------------------------------------
|
||||
# Generate: openssl rand -base64 48
|
||||
JWT_SECRET=replace_with_openssl_rand_base64_48
|
||||
|
||||
# MinIO browser CORS — your public SPA origin (scheme + host, no trailing slash).
|
||||
MINIO_API_CORS_ALLOW_ORIGIN=https://www.example.com
|
||||
|
||||
# Postgres + password caveat:
|
||||
# Changing POSTGRES_USER/POSTGRES_PASSWORD here later does NOT change an existing Docker volume —
|
||||
# Postgres only reads them when /var/lib/postgresql/data is empty. If login fails after editing .env:
|
||||
# • Use the same password as first boot (e.g. dev stack used initiative / initiative_secret), or
|
||||
# • With docker-compose.prod.yml stopped: docker volume rm …_initiative_pg_data then up again (drops DB), or
|
||||
# • Run ./scripts/sync-postgres-app-password.sh to set the DB role password from this file (no wipe), or
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMTP — outbound mail from be0 (registration OTP, password reset)
|
||||
# ---------------------------------------------------------------------------
|
||||
# docker-compose / docker-compose.prod passes these into the be0 container.
|
||||
# Compose substitutes ${SMTP_*} from THIS file (repo-root `.env`), not from be0/.env alone.
|
||||
# Omit AUTH_MAIL_LOG_ONLY (or set 0/false) when using real SMTP.
|
||||
#
|
||||
# SMTP_HOST=smtp.your-mail-provider.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=your_smtp_username
|
||||
# SMTP_PASSWORD=your_smtp_password
|
||||
# AUTH_MAIL_FROM=noreply@your-institution.edu.vn
|
||||
# SMTP_USE_TLS=1
|
||||
#
|
||||
# Public URL of the web app (password-reset / verify links in email). Production example:
|
||||
# AUTH_PUBLIC_WEB_ORIGIN=https://your-app.example.com
|
||||
#
|
||||
# Dev-only: print OTP in be0 logs instead of sending mail
|
||||
# AUTH_MAIL_LOG_ONLY=1
|
||||
#
|
||||
# Microsoft 365 / Outlook (smtp.office365.com), log shows 535 Authentication unsuccessful:
|
||||
# • SMTP_USER = full mailbox address; SMTP_PASSWORD = correct app password if MFA is enabled
|
||||
# (not your normal web-login password unless basic auth is allowed — many tenants require app passwords).
|
||||
# • Exchange admin: enable "Authenticated SMTP" for the mailbox; security defaults may block SMTP AUTH.
|
||||
# • After editing .env: docker compose up -d be0 (so the container reloads env).
|
||||
@@ -0,0 +1,99 @@
|
||||
name: CI/CD
|
||||
|
||||
# Gitea Actions pipeline for the UMP / ImageHub monorepo.
|
||||
# backend — be0 (FastAPI, Python 3.11) pytest against a throwaway Postgres
|
||||
# frontend — npm workspaces (shared + 4 Vite/React SPAs): typecheck, build, unit tests
|
||||
# deploy — on push to main only: build + `docker compose up -d` on the host runner
|
||||
#
|
||||
# Runner labels expected (act_runner registered on 103.149.170.102):
|
||||
# ci -> docker mode (clean, ephemeral) used by backend + frontend
|
||||
# deploy -> host mode (drives host docker) used by deploy
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ci
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: initiative
|
||||
POSTGRES_PASSWORD: initiative_secret
|
||||
POSTGRES_DB: initiatives
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U initiative -d initiatives"
|
||||
--health-interval 5s --health-timeout 5s --health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install backend deps (+ test deps)
|
||||
working-directory: be0
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
- name: Unit tests — pytest PER FILE (isolates asyncpg event loop per module)
|
||||
working-directory: be0
|
||||
env:
|
||||
INITIATIVE_DATABASE_URL: postgresql+asyncpg://initiative:initiative_secret@postgres:5432/initiatives
|
||||
run: |
|
||||
set -e
|
||||
fail=0
|
||||
for f in tests/test_*.py; do
|
||||
echo "::group::$f"
|
||||
python -m pytest "$f" -q || fail=1
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit $fail
|
||||
|
||||
frontend:
|
||||
runs-on: ci
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Install (workspaces)
|
||||
run: npm ci
|
||||
- name: Typecheck (all workspaces)
|
||||
run: npm run typecheck
|
||||
- name: Build (all workspaces)
|
||||
run: npm run build
|
||||
- name: Unit tests (workspaces w/ vitest — shared, investigator, publisher)
|
||||
run: npm test --workspaces --if-present
|
||||
|
||||
# Deploy runs in HOST mode from a PERSISTENT dir (NOT the ephemeral runner
|
||||
# workspace): docker-compose.prod.yml bind-mounts ./assets/minio-data and
|
||||
# ./be0, so MinIO data + submitted files must live on a stable host path or
|
||||
# they would be wiped on every deploy.
|
||||
deploy:
|
||||
needs: [backend, frontend]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: deploy
|
||||
steps:
|
||||
- name: Sync code to persistent deploy dir
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEPLOY_DIR=/srv/sciagent
|
||||
if [ ! -d "$DEPLOY_DIR/.git" ]; then
|
||||
git clone http://localhost:3000/tlam89/sciagent.git "$DEPLOY_DIR"
|
||||
fi
|
||||
cd "$DEPLOY_DIR"
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
- name: Materialize prod .env from secret
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s' "${{ secrets.PROD_ENV }}" > /srv/sciagent/.env
|
||||
chmod 600 /srv/sciagent/.env
|
||||
- name: Deploy stack (build locally, no registry pull)
|
||||
run: cd /srv/sciagent && bash scripts/deploy-prod.sh --no-pull
|
||||
- name: Stack health check
|
||||
run: cd /srv/sciagent && bash scripts/check-prod-stack.sh
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Secrets — commit only `.env.example`, never `.env`.
|
||||
.env
|
||||
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Keep the example/template
|
||||
!.env.example
|
||||
|
||||
assets/minio-data/*
|
||||
|
||||
be0/.venv/
|
||||
|
||||
# HMW-mode marker — session-local toggle (/ultra-on … /ultra-off). Never commit;
|
||||
# committing it would leave a fresh `git clone` stuck in token-burn mode.
|
||||
.claude/hmw-mode.on
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,137 @@
|
||||
-- =============================================================================
|
||||
-- CRUD PATTERNS — Sáng kiến application system
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- CREATE: Submit a new application with multiple authors (atomic)
|
||||
-- =============================================================================
|
||||
BEGIN;
|
||||
-- Set audit context
|
||||
SELECT set_config('my.user_id', '42', true);
|
||||
|
||||
-- 1. Main record
|
||||
INSERT INTO applications(code, title, registration_year, status, purpose,
|
||||
is_technical_solution, primary_unit_id, created_by)
|
||||
VALUES ('SK-2025-007',
|
||||
'Hệ thống tự động điền hồ sơ sáng kiến',
|
||||
2025, 'DRAFT',
|
||||
'Tự động hoá việc điền các mẫu số 01–04',
|
||||
TRUE, 2, 42)
|
||||
RETURNING application_id \gset
|
||||
|
||||
-- 2. Authors (defer contribution-sum check until COMMIT)
|
||||
SET CONSTRAINTS trg_contribution_total DEFERRED;
|
||||
INSERT INTO application_authors(application_id, user_id, contribution_pct, role, display_order) VALUES
|
||||
(:application_id, 42, 60.00, 'PRIMARY', 1),
|
||||
(:application_id, 13, 25.00, 'CO_AUTHOR', 2),
|
||||
(:application_id, 27, 15.00, 'CO_AUTHOR', 3);
|
||||
|
||||
-- 3. Orgs that tested it
|
||||
INSERT INTO application_adopters(application_id, org_name, address, field) VALUES
|
||||
(:application_id, 'Phòng KHCN', '217 Hồng Bàng, Q.5', 'Cải cách hành chính');
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- READ: Dashboard — paginated list with filters
|
||||
-- =============================================================================
|
||||
SELECT * FROM v_application_summary
|
||||
WHERE registration_year = 2025
|
||||
AND status = ANY(ARRAY['UNDER_REVIEW','EVALUATED']::text[])
|
||||
AND title ILIKE '%động vật%' -- uses trigram index
|
||||
ORDER BY avg_score DESC NULLS LAST, submitted_at DESC
|
||||
LIMIT 20 OFFSET 0;
|
||||
|
||||
-- Read: full application with nested data (app layer usually does this as N queries
|
||||
-- or one JSON aggregate — here's the aggregate version)
|
||||
SELECT jsonb_build_object(
|
||||
'application', to_jsonb(a.*),
|
||||
'authors', (SELECT jsonb_agg(jsonb_build_object(
|
||||
'user_id', u.user_id,
|
||||
'name', u.full_name,
|
||||
'pct', aa.contribution_pct,
|
||||
'role', aa.role
|
||||
) ORDER BY aa.display_order)
|
||||
FROM application_authors aa
|
||||
JOIN users u USING (user_id)
|
||||
WHERE aa.application_id = a.application_id),
|
||||
'evaluations',(SELECT jsonb_agg(to_jsonb(e.*))
|
||||
FROM evaluations e WHERE e.application_id = a.application_id),
|
||||
'attachments',(SELECT jsonb_agg(to_jsonb(att.*))
|
||||
FROM attachments att WHERE att.application_id = a.application_id)
|
||||
) AS document
|
||||
FROM applications a
|
||||
WHERE a.application_id = 1 AND a.deleted_at IS NULL;
|
||||
|
||||
-- Full-text search (Vietnamese-friendly; combine with unaccent for better recall)
|
||||
SELECT application_id, code, title
|
||||
FROM applications
|
||||
WHERE to_tsvector('simple', title || ' ' || coalesce(introduction,''))
|
||||
@@ plainto_tsquery('simple', 'đạo đức động vật')
|
||||
ORDER BY registration_year DESC
|
||||
LIMIT 10;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- UPDATE: Progress an application through the workflow
|
||||
-- =============================================================================
|
||||
-- Submit (DRAFT → SUBMITTED). Triggers populate submitted_at automatically.
|
||||
UPDATE applications SET status = 'SUBMITTED' WHERE application_id = 7;
|
||||
|
||||
-- Assign to review panel
|
||||
UPDATE applications SET status = 'UNDER_REVIEW' WHERE application_id = 7;
|
||||
|
||||
-- Upsert an evaluation (same evaluator re-scores)
|
||||
INSERT INTO evaluations (application_id, evaluator_id, novelty_score, effectiveness_score, conclusion)
|
||||
VALUES (7, 99, 32, 48, 'Đề nghị công nhận')
|
||||
ON CONFLICT (application_id, evaluator_id)
|
||||
DO UPDATE SET
|
||||
novelty_score = EXCLUDED.novelty_score,
|
||||
effectiveness_score = EXCLUDED.effectiveness_score,
|
||||
conclusion = EXCLUDED.conclusion,
|
||||
evaluated_at = NOW();
|
||||
|
||||
-- Update JSONB field: patch a single effectiveness sub-field
|
||||
UPDATE applications
|
||||
SET effectiveness = effectiveness || jsonb_build_object(
|
||||
'economic',
|
||||
'Tiết kiệm ~30% thời gian xét duyệt'
|
||||
)
|
||||
WHERE application_id = 7;
|
||||
|
||||
-- Partial update (PATCH-style) — only update provided fields. The app layer
|
||||
-- generates SET clauses from the non-null fields in the request body.
|
||||
UPDATE applications
|
||||
SET title = COALESCE($1, title),
|
||||
purpose = COALESCE($2, purpose),
|
||||
updated_at = NOW()
|
||||
WHERE application_id = $3 AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- DELETE: Soft delete + restore
|
||||
-- =============================================================================
|
||||
-- Soft delete
|
||||
UPDATE applications SET deleted_at = NOW() WHERE application_id = 7;
|
||||
|
||||
-- Restore
|
||||
UPDATE applications SET deleted_at = NULL WHERE application_id = 7;
|
||||
|
||||
-- Hard delete (only for drafts, cascades to authors/evaluations/etc.)
|
||||
DELETE FROM applications
|
||||
WHERE application_id = 7
|
||||
AND status = 'DRAFT';
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- ANALYTICS: Materialized-view refresh (run nightly via cron/pgAgent)
|
||||
-- =============================================================================
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_annual_stats;
|
||||
|
||||
-- Leaderboard: top-scoring approved innovations
|
||||
SELECT code, title, avg_score
|
||||
FROM v_application_summary
|
||||
WHERE status = 'APPROVED'
|
||||
ORDER BY avg_score DESC
|
||||
LIMIT 10;
|
||||
@@ -0,0 +1,422 @@
|
||||
-- =============================================================================
|
||||
-- SÁNG KIẾN (INNOVATION APPLICATION) DATABASE SCHEMA
|
||||
-- PostgreSQL 14+
|
||||
--
|
||||
-- Domain: Manage innovation applications at ĐHYD TP.HCM (Vietnamese medical
|
||||
-- university). Supports the full lifecycle: draft → submit → evaluate → approve.
|
||||
--
|
||||
-- Design principles:
|
||||
-- - 3NF for entities, JSONB for semi-structured/optional narrative
|
||||
-- - Soft delete (deleted_at) — legal/audit requires historical retention
|
||||
-- - State machine on applications.status enforced by trigger
|
||||
-- - Full audit_log via trigger on all CUD operations
|
||||
-- - Contribution % sums to 100 enforced by DEFERRABLE trigger
|
||||
-- =============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- fuzzy matching
|
||||
CREATE EXTENSION IF NOT EXISTS unaccent; -- Vietnamese diacritics in search
|
||||
|
||||
-- Convenience: updated_at auto-maintenance
|
||||
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS TRIGGER AS $$
|
||||
BEGIN NEW.updated_at := NOW(); RETURN NEW; END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- REFERENCE: UNITS (departments, faculties, centers)
|
||||
-- =============================================================================
|
||||
CREATE TABLE units (
|
||||
unit_id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(32) UNIQUE NOT NULL,
|
||||
name VARCHAR(255) NOT NULL, -- full Vietnamese name
|
||||
parent_unit_id INT REFERENCES units(unit_id) ON DELETE SET NULL,
|
||||
type VARCHAR(32) NOT NULL
|
||||
CHECK (type IN ('TRUONG','KHOA','PHONG','BO_MON','TRUNG_TAM','KHAC')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE TRIGGER trg_units_touch BEFORE UPDATE ON units
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- USERS (unified: authors, evaluators, admins — a user can wear many hats)
|
||||
-- =============================================================================
|
||||
CREATE TABLE users (
|
||||
user_id SERIAL PRIMARY KEY,
|
||||
full_name VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(64), -- PGS.TS, TS., GS., CN., ThS.
|
||||
date_of_birth DATE,
|
||||
email VARCHAR(255) UNIQUE,
|
||||
phone VARCHAR(32),
|
||||
id_number VARCHAR(32) UNIQUE, -- CCCD / hộ chiếu
|
||||
unit_id INT REFERENCES units(unit_id) ON DELETE SET NULL,
|
||||
position VARCHAR(255), -- chức danh: Trưởng phòng, GV cao cấp
|
||||
qualification VARCHAR(64), -- trình độ: Tiến sĩ, Thạc sĩ, Cử nhân
|
||||
user_type VARCHAR(32) NOT NULL DEFAULT 'AUTHOR'
|
||||
CHECK (user_type IN ('AUTHOR','COUNCIL','ADMIN','STUDENT','EXTERNAL')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
deleted_at TIMESTAMPTZ, -- soft delete
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_users_unit ON users(unit_id);
|
||||
CREATE INDEX idx_users_active ON users(is_active) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_users_name_trgm ON users USING GIN (full_name gin_trgm_ops);
|
||||
CREATE TRIGGER trg_users_touch BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- APPLICATIONS (sáng kiến) — the core entity
|
||||
-- =============================================================================
|
||||
CREATE TABLE applications (
|
||||
application_id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(32) UNIQUE NOT NULL, -- e.g., 'SK-2025-001'
|
||||
title TEXT NOT NULL,
|
||||
title_en TEXT,
|
||||
registration_year INT NOT NULL CHECK (registration_year BETWEEN 2000 AND 2100),
|
||||
field_of_application TEXT, -- lĩnh vực áp dụng
|
||||
|
||||
-- Workflow state (enforced by trigger below)
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT'
|
||||
CHECK (status IN (
|
||||
'DRAFT','SUBMITTED','UNDER_REVIEW',
|
||||
'EVALUATED','APPROVED','REJECTED','WITHDRAWN'
|
||||
)),
|
||||
|
||||
-- Mẫu 01 narrative (long text)
|
||||
introduction TEXT, -- 1. Mở đầu
|
||||
current_state TEXT, -- 4.1 Tình trạng đã biết
|
||||
purpose TEXT, -- Mục đích
|
||||
implementation_steps TEXT, -- Các bước thực hiện
|
||||
required_conditions TEXT, -- Điều kiện cần thiết
|
||||
results_achieved TEXT, -- Kết quả thu được
|
||||
novelty_description TEXT, -- Tính mới
|
||||
confidential_info TEXT, -- Thông tin cần bảo mật
|
||||
|
||||
-- 10 effectiveness sub-fields (all optional narrative) → JSONB
|
||||
effectiveness JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- Shape: { "economic":"...", "teaching":"...", "productivity":"...",
|
||||
-- "work_efficiency":"...", "quality":"...", "cost_reduction":"...",
|
||||
-- "environment":"...", "health":"...", "safety":"...", "awareness":"..." }
|
||||
|
||||
-- Mẫu 02 fields
|
||||
owner_org VARCHAR(255), -- chủ đầu tư
|
||||
first_applied_date DATE, -- ngày áp dụng lần đầu
|
||||
content_summary TEXT, -- nội dung sáng kiến (short)
|
||||
author_assessment TEXT, -- đánh giá theo tác giả
|
||||
org_assessment TEXT, -- đánh giá theo tổ chức
|
||||
|
||||
-- Mẫu 02 classification (mutually exclusive in form, but stored as flags)
|
||||
is_technical_solution BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_from_research_article BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_from_book_material BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
CONSTRAINT chk_exactly_one_classification CHECK (
|
||||
status = 'DRAFT' OR
|
||||
(is_technical_solution::int + is_from_research_article::int + is_from_book_material::int) = 1
|
||||
),
|
||||
|
||||
-- Workflow timestamps
|
||||
submitted_at TIMESTAMPTZ,
|
||||
decided_at TIMESTAMPTZ,
|
||||
|
||||
primary_unit_id INT REFERENCES units(unit_id),
|
||||
created_by INT REFERENCES users(user_id),
|
||||
deleted_at TIMESTAMPTZ, -- soft delete
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_apps_status ON applications(status) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_apps_year ON applications(registration_year);
|
||||
CREATE INDEX idx_apps_unit ON applications(primary_unit_id);
|
||||
CREATE INDEX idx_apps_title_trgm ON applications USING GIN (title gin_trgm_ops);
|
||||
CREATE INDEX idx_apps_fts ON applications USING GIN (
|
||||
to_tsvector('simple',
|
||||
coalesce(title,'') || ' ' ||
|
||||
coalesce(introduction,'') || ' ' ||
|
||||
coalesce(novelty_description,'')
|
||||
)
|
||||
);
|
||||
CREATE INDEX idx_apps_effectiveness ON applications USING GIN (effectiveness);
|
||||
CREATE TRIGGER trg_apps_touch BEFORE UPDATE ON applications
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- APPLICATION_AUTHORS (M:N with contribution %)
|
||||
-- =============================================================================
|
||||
CREATE TABLE application_authors (
|
||||
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
|
||||
user_id INT NOT NULL REFERENCES users(user_id),
|
||||
contribution_pct NUMERIC(5,2) NOT NULL CHECK (contribution_pct > 0 AND contribution_pct <= 100),
|
||||
role VARCHAR(32) NOT NULL DEFAULT 'CO_AUTHOR'
|
||||
CHECK (role IN ('PRIMARY','CO_AUTHOR')),
|
||||
display_order INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (application_id, user_id)
|
||||
);
|
||||
CREATE INDEX idx_app_authors_user ON application_authors(user_id);
|
||||
|
||||
-- At most one PRIMARY author per application
|
||||
CREATE UNIQUE INDEX uq_primary_per_app
|
||||
ON application_authors(application_id) WHERE role = 'PRIMARY';
|
||||
|
||||
-- Deferrable check: contribution % must total 100 per application
|
||||
CREATE OR REPLACE FUNCTION check_contribution_total() RETURNS TRIGGER AS $$
|
||||
DECLARE v_total NUMERIC; v_app INT;
|
||||
BEGIN
|
||||
v_app := COALESCE(NEW.application_id, OLD.application_id);
|
||||
SELECT COALESCE(SUM(contribution_pct),0) INTO v_total
|
||||
FROM application_authors WHERE application_id = v_app;
|
||||
-- Only enforce when application has left DRAFT
|
||||
IF (SELECT status FROM applications WHERE application_id = v_app) <> 'DRAFT'
|
||||
AND v_total <> 100 THEN
|
||||
RAISE EXCEPTION 'Contribution % for application % must sum to 100 (got %)',
|
||||
'%', v_app, v_total;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_contribution_total
|
||||
AFTER INSERT OR UPDATE OR DELETE ON application_authors
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW EXECUTE FUNCTION check_contribution_total();
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- ORGS that tested / adopted the innovation (Mẫu 01 inner table)
|
||||
-- =============================================================================
|
||||
CREATE TABLE application_adopters (
|
||||
adopter_id SERIAL PRIMARY KEY,
|
||||
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
|
||||
display_order INT NOT NULL DEFAULT 0,
|
||||
org_name VARCHAR(255) NOT NULL,
|
||||
address TEXT,
|
||||
field TEXT
|
||||
);
|
||||
CREATE INDEX idx_adopters_app ON application_adopters(application_id);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- PARTICIPANTS in first application (Mẫu 02 inner table)
|
||||
-- =============================================================================
|
||||
CREATE TABLE application_participants (
|
||||
participant_id SERIAL PRIMARY KEY,
|
||||
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
|
||||
user_id INT REFERENCES users(user_id), -- optional link
|
||||
display_order INT NOT NULL DEFAULT 0,
|
||||
full_name VARCHAR(255) NOT NULL,
|
||||
date_of_birth DATE,
|
||||
work_unit VARCHAR(255),
|
||||
position VARCHAR(255),
|
||||
qualification VARCHAR(64),
|
||||
support_content TEXT
|
||||
);
|
||||
CREATE INDEX idx_participants_app ON application_participants(application_id);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- EVALUATIONS (Mẫu 04) — council members score applications
|
||||
-- =============================================================================
|
||||
CREATE TABLE evaluations (
|
||||
evaluation_id SERIAL PRIMARY KEY,
|
||||
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
|
||||
evaluator_id INT NOT NULL REFERENCES users(user_id),
|
||||
|
||||
novelty_comments TEXT,
|
||||
novelty_score INT NOT NULL DEFAULT 0
|
||||
CHECK (novelty_score BETWEEN 0 AND 40),
|
||||
|
||||
effectiveness_comments TEXT,
|
||||
effectiveness_score INT NOT NULL DEFAULT 0
|
||||
CHECK (effectiveness_score BETWEEN 0 AND 60),
|
||||
|
||||
total_score INT GENERATED ALWAYS AS (novelty_score + effectiveness_score) STORED,
|
||||
conclusion TEXT,
|
||||
evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (application_id, evaluator_id)
|
||||
);
|
||||
CREATE INDEX idx_eval_app ON evaluations(application_id);
|
||||
CREATE INDEX idx_eval_evaluator ON evaluations(evaluator_id);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- COMMITMENTS (Bản cam kết) — for paper-based innovations
|
||||
-- =============================================================================
|
||||
CREATE TABLE commitments (
|
||||
commitment_id SERIAL PRIMARY KEY,
|
||||
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
|
||||
user_id INT NOT NULL REFERENCES users(user_id),
|
||||
|
||||
paper_title TEXT,
|
||||
role_type VARCHAR(32) NOT NULL
|
||||
CHECK (role_type IN ('PRIMARY_AUTHOR','CO_AUTHOR')),
|
||||
|
||||
-- 5 commitment checkboxes
|
||||
is_legal_owner BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_authorized_by_owner BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
has_coauthor_consent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
not_predatory_journal BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
complies_with_ip_law BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (application_id, user_id)
|
||||
);
|
||||
CREATE INDEX idx_commit_app ON commitments(application_id);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- ATTACHMENTS (uploaded files — figures, flowcharts, annexes)
|
||||
-- =============================================================================
|
||||
CREATE TABLE attachments (
|
||||
attachment_id SERIAL PRIMARY KEY,
|
||||
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_path TEXT NOT NULL, -- S3/MinIO key
|
||||
file_size BIGINT,
|
||||
mime_type VARCHAR(128),
|
||||
kind VARCHAR(32) -- 'LUU_DO', 'PHU_LUC', 'KY_SO', 'KHAC'
|
||||
CHECK (kind IS NULL OR kind IN ('LUU_DO','PHU_LUC','KY_SO','KHAC')),
|
||||
uploaded_by INT REFERENCES users(user_id),
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_attach_app ON attachments(application_id);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- AUDIT LOG — single table, populated by triggers on all CUD operations
|
||||
-- =============================================================================
|
||||
CREATE TABLE audit_log (
|
||||
log_id BIGSERIAL PRIMARY KEY,
|
||||
table_name VARCHAR(64) NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
action VARCHAR(16) NOT NULL CHECK (action IN ('INSERT','UPDATE','DELETE')),
|
||||
changed_by INT, -- set from app via SET LOCAL my.user_id
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
old_data JSONB,
|
||||
new_data JSONB
|
||||
);
|
||||
CREATE INDEX idx_audit_table_record ON audit_log(table_name, record_id);
|
||||
CREATE INDEX idx_audit_user_time ON audit_log(changed_by, changed_at DESC);
|
||||
|
||||
-- Generic audit trigger function
|
||||
CREATE OR REPLACE FUNCTION audit_trigger() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_user INT;
|
||||
v_pk TEXT;
|
||||
BEGIN
|
||||
-- Get user_id from session var if app sets it; else NULL
|
||||
BEGIN v_user := current_setting('my.user_id')::INT;
|
||||
EXCEPTION WHEN OTHERS THEN v_user := NULL; END;
|
||||
|
||||
v_pk := COALESCE(
|
||||
(row_to_json(NEW)::jsonb->>TG_ARGV[0]),
|
||||
(row_to_json(OLD)::jsonb->>TG_ARGV[0])
|
||||
);
|
||||
|
||||
INSERT INTO audit_log(table_name, record_id, action, changed_by, old_data, new_data)
|
||||
VALUES (
|
||||
TG_TABLE_NAME,
|
||||
v_pk,
|
||||
TG_OP,
|
||||
v_user,
|
||||
CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) END,
|
||||
CASE WHEN TG_OP IN ('INSERT','UPDATE') THEN to_jsonb(NEW) END
|
||||
);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Attach audit trigger to the important tables (pass PK column name as arg)
|
||||
CREATE TRIGGER trg_audit_applications AFTER INSERT OR UPDATE OR DELETE ON applications
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_trigger('application_id');
|
||||
CREATE TRIGGER trg_audit_authors AFTER INSERT OR UPDATE OR DELETE ON application_authors
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_trigger('application_id');
|
||||
CREATE TRIGGER trg_audit_evaluations AFTER INSERT OR UPDATE OR DELETE ON evaluations
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_trigger('evaluation_id');
|
||||
CREATE TRIGGER trg_audit_commitments AFTER INSERT OR UPDATE OR DELETE ON commitments
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_trigger('commitment_id');
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- WORKFLOW STATE MACHINE ENFORCEMENT
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION enforce_application_transitions() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
allowed BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
IF OLD.status = NEW.status THEN RETURN NEW; END IF;
|
||||
|
||||
-- Allowed transitions
|
||||
allowed := CASE
|
||||
WHEN OLD.status = 'DRAFT' AND NEW.status IN ('SUBMITTED','WITHDRAWN') THEN TRUE
|
||||
WHEN OLD.status = 'SUBMITTED' AND NEW.status IN ('UNDER_REVIEW','WITHDRAWN','DRAFT') THEN TRUE
|
||||
WHEN OLD.status = 'UNDER_REVIEW' AND NEW.status IN ('EVALUATED','WITHDRAWN') THEN TRUE
|
||||
WHEN OLD.status = 'EVALUATED' AND NEW.status IN ('APPROVED','REJECTED') THEN TRUE
|
||||
ELSE FALSE
|
||||
END;
|
||||
|
||||
IF NOT allowed THEN
|
||||
RAISE EXCEPTION 'Invalid status transition: % → %', OLD.status, NEW.status;
|
||||
END IF;
|
||||
|
||||
-- Auto-set timestamps
|
||||
IF NEW.status = 'SUBMITTED' AND OLD.status = 'DRAFT' THEN
|
||||
NEW.submitted_at := NOW();
|
||||
END IF;
|
||||
IF NEW.status IN ('APPROVED','REJECTED') THEN
|
||||
NEW.decided_at := NOW();
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_app_state_machine
|
||||
BEFORE UPDATE OF status ON applications
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_application_transitions();
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- CONVENIENCE VIEWS
|
||||
-- =============================================================================
|
||||
|
||||
-- Dashboard: applications with author names and current evaluation average
|
||||
CREATE VIEW v_application_summary AS
|
||||
SELECT
|
||||
a.application_id,
|
||||
a.code,
|
||||
a.title,
|
||||
a.status,
|
||||
a.registration_year,
|
||||
u.name AS primary_unit_name,
|
||||
(SELECT string_agg(usr.full_name, ', ' ORDER BY aa.display_order)
|
||||
FROM application_authors aa
|
||||
JOIN users usr ON usr.user_id = aa.user_id
|
||||
WHERE aa.application_id = a.application_id) AS author_names,
|
||||
(SELECT ROUND(AVG(total_score),2)
|
||||
FROM evaluations WHERE application_id = a.application_id) AS avg_score,
|
||||
(SELECT COUNT(*) FROM evaluations WHERE application_id = a.application_id) AS num_evaluations,
|
||||
a.submitted_at,
|
||||
a.decided_at
|
||||
FROM applications a
|
||||
LEFT JOIN units u ON u.unit_id = a.primary_unit_id
|
||||
WHERE a.deleted_at IS NULL;
|
||||
|
||||
-- Materialized view: annual approval statistics (refresh nightly)
|
||||
CREATE MATERIALIZED VIEW mv_annual_stats AS
|
||||
SELECT
|
||||
registration_year,
|
||||
COUNT(*) FILTER (WHERE status = 'APPROVED') AS approved,
|
||||
COUNT(*) FILTER (WHERE status = 'REJECTED') AS rejected,
|
||||
COUNT(*) FILTER (WHERE status NOT IN ('APPROVED','REJECTED')) AS pending,
|
||||
COUNT(*) AS total
|
||||
FROM applications
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY registration_year;
|
||||
CREATE UNIQUE INDEX ON mv_annual_stats(registration_year);
|
||||
@@ -0,0 +1,83 @@
|
||||
-- Validation tests: run in a single transaction per block
|
||||
-- ===========================================================
|
||||
|
||||
-- 1. SEED: units + users
|
||||
INSERT INTO units(code, name, type) VALUES
|
||||
('DHYD', 'Đại học Y Dược TP.HCM', 'TRUONG'),
|
||||
('KHCN', 'Phòng Khoa học Công nghệ', 'PHONG');
|
||||
|
||||
INSERT INTO users(full_name, title, email, id_number, unit_id, qualification, user_type) VALUES
|
||||
('Trần Hùng', 'PGS.TS', 'tranhung@ump.edu.vn', '001001', 1, 'Tiến sĩ', 'AUTHOR'),
|
||||
('Đỗ Quốc Vũ', 'CN.', 'doquocvu@ump.edu.vn', '001002', 2, 'Cử nhân', 'AUTHOR'),
|
||||
('Nguyễn Hội đồng A', 'PGS.TS', 'hdA@ump.edu.vn', '002001', 1, 'Tiến sĩ', 'COUNCIL');
|
||||
|
||||
-- 2. CREATE an application in DRAFT state
|
||||
INSERT INTO applications(code, title, registration_year, status, purpose, primary_unit_id, created_by)
|
||||
VALUES ('SK-2025-001',
|
||||
'Quy trình xét duyệt Đạo đức trong nghiên cứu trên động vật',
|
||||
2025, 'DRAFT',
|
||||
'Chuẩn hoá quy trình xét duyệt hồ sơ',
|
||||
2, 2);
|
||||
|
||||
-- 3. ADD authors with DEFERRED constraint (sums to 100 at COMMIT)
|
||||
BEGIN;
|
||||
INSERT INTO application_authors(application_id, user_id, contribution_pct, role) VALUES
|
||||
(1, 1, 50, 'CO_AUTHOR'),
|
||||
(1, 2, 50, 'PRIMARY');
|
||||
-- At this point sum=100, but app is DRAFT so constraint doesn't even care yet
|
||||
COMMIT;
|
||||
|
||||
-- Verify
|
||||
SELECT 'Authors inserted:' AS step, count(*) FROM application_authors;
|
||||
|
||||
-- 4. TRY to submit the application (DRAFT → SUBMITTED): needs classification
|
||||
-- This should FAIL the check constraint because no classification flag is set
|
||||
\echo 'Test 4: should FAIL (missing classification)'
|
||||
UPDATE applications SET status='SUBMITTED' WHERE application_id=1;
|
||||
\echo ''
|
||||
|
||||
-- Fix and retry
|
||||
UPDATE applications
|
||||
SET is_technical_solution = TRUE,
|
||||
status = 'SUBMITTED'
|
||||
WHERE application_id = 1;
|
||||
SELECT 'After submit:' AS step, status, submitted_at FROM applications WHERE application_id=1;
|
||||
|
||||
-- 5. TRY invalid transition SUBMITTED → APPROVED (should FAIL)
|
||||
\echo 'Test 5: should FAIL (illegal transition)'
|
||||
UPDATE applications SET status='APPROVED' WHERE application_id=1;
|
||||
\echo ''
|
||||
|
||||
-- Valid transitions
|
||||
UPDATE applications SET status='UNDER_REVIEW' WHERE application_id=1;
|
||||
|
||||
-- 6. EVALUATOR scores the application
|
||||
INSERT INTO evaluations(application_id, evaluator_id, novelty_score, effectiveness_score, conclusion)
|
||||
VALUES (1, 3, 35, 50, 'Đề xuất công nhận');
|
||||
|
||||
SELECT 'Evaluation:' AS step, novelty_score, effectiveness_score, total_score FROM evaluations;
|
||||
|
||||
-- 7. Move to EVALUATED → APPROVED
|
||||
UPDATE applications SET status='EVALUATED' WHERE application_id=1;
|
||||
UPDATE applications SET status='APPROVED' WHERE application_id=1;
|
||||
|
||||
SELECT 'Final status:' AS step, status, decided_at IS NOT NULL AS has_decision_time
|
||||
FROM applications WHERE application_id=1;
|
||||
|
||||
-- 8. READ: summary view
|
||||
SELECT code, title, status, author_names, avg_score, num_evaluations
|
||||
FROM v_application_summary;
|
||||
|
||||
-- 9. AUDIT trail: who changed what?
|
||||
SELECT table_name, action, changed_at,
|
||||
(new_data->>'status') AS new_status
|
||||
FROM audit_log
|
||||
WHERE table_name = 'applications'
|
||||
ORDER BY log_id;
|
||||
|
||||
-- 10. Bad contribution sum should fail at COMMIT
|
||||
\echo 'Test 10: should FAIL (sum != 100 on submitted app)'
|
||||
BEGIN;
|
||||
UPDATE application_authors SET contribution_pct = 30 WHERE application_id=1 AND user_id=1;
|
||||
-- sum is now 30+50=80, but app is APPROVED so trigger will reject at commit
|
||||
COMMIT;
|
||||
@@ -0,0 +1,254 @@
|
||||
Initiative Management System
|
||||
|
||||
The platform consists of two main services:
|
||||
|
||||
- **Frontend**: React-based web application with TypeScript and Vite
|
||||
- **Backend**: FastAPI-based REST API with Python 3.11
|
||||
- **AI Integration**: Ollama-powered document analysis and compliance checking
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
poc/
|
||||
├── fe0/ # Frontend service
|
||||
│ ├── src/ # React application source
|
||||
│ ├── public/ # Static assets
|
||||
│ ├── package.json # Node.js dependencies
|
||||
│ └── Dockerfile # Frontend container
|
||||
├── be0/ # Backend service
|
||||
│ ├── src/ # Python application source
|
||||
│ ├── main.py # FastAPI application entry point
|
||||
│ ├── requirements.txt # Python dependencies
|
||||
│ └── Dockerfile # Backend container
|
||||
├── assets/ # Shared resources and data
|
||||
└── docker-compose.yml # Service orchestration
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- Git
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Clone and setup**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd poc
|
||||
```
|
||||
|
||||
2. **Start all services**
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
3. **Access the application**
|
||||
- **Frontend**: http://localhost:8081
|
||||
- **Backend API**: http://localhost:4402
|
||||
- **API Documentation**: http://localhost:4402/docs
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Frontend Development
|
||||
|
||||
```bash
|
||||
cd fe0
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Available Scripts:**
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview production build
|
||||
- `npm run lint` - Run ESLint
|
||||
|
||||
**Technology Stack:**
|
||||
- React 18 with TypeScript
|
||||
- Vite for build tooling
|
||||
- Tailwind CSS for styling
|
||||
- shadcn/ui component library
|
||||
- React Router for navigation
|
||||
- TanStack Query for state management
|
||||
|
||||
### Backend Development
|
||||
|
||||
```bash
|
||||
cd be0
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --host 0.0.0.0 --port 4402 --reload
|
||||
```
|
||||
|
||||
**Technology Stack:**
|
||||
- FastAPI framework
|
||||
- Python 3.11
|
||||
- Pydantic for data validation
|
||||
- LangChain for AI workflows
|
||||
- Ollama for local AI models
|
||||
- PDF processing with PyPDF and Docling
|
||||
|
||||
## API Documentation
|
||||
|
||||
### Core Endpoints
|
||||
|
||||
#### Workflow Management
|
||||
- `POST /workflows` - Initialize new compliance workflow
|
||||
- `GET /workflows/{workflow_id}` - Retrieve workflow status
|
||||
- `PUT /workflows/{workflow_id}/items` - Update workflow items
|
||||
- `POST /workflows/{workflow_id}/approvals` - Submit approvals
|
||||
- `GET /workflows/{workflow_id}/report` - Generate status reports
|
||||
- `POST /workflows/{workflow_id}/advance` - Progress to next phase
|
||||
|
||||
#### Document Processing
|
||||
- `POST /upload_document` - Upload and parse documents
|
||||
- `POST /get_page` - Retrieve specific document pages
|
||||
- `POST /test_ollama` - Test AI model connectivity
|
||||
|
||||
#### System Health
|
||||
- `GET /health` - Service health check
|
||||
- `GET /` - API information and available endpoints
|
||||
|
||||
### Request/Response Examples
|
||||
|
||||
**Create Workflow:**
|
||||
```json
|
||||
POST /workflows
|
||||
{
|
||||
"project_name": "ISO 27001 Implementation",
|
||||
"project_description": "Implement ISO 27001 controls",
|
||||
"records_officer_email": "officer@company.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Update Workflow Item:**
|
||||
```json
|
||||
PUT /workflows/{workflow_id}/items
|
||||
{
|
||||
"item_id": 1,
|
||||
"status": "completed",
|
||||
"comment": "Implementation completed",
|
||||
"updated_by": "john.doe@company.com"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `GENERIC_TIMEZONE`` | Application timezone | `UTC` |
|
||||
| `NVIDIA_VISIBLE_DEVICES` | GPU access for AI models | `all` |
|
||||
| `NVIDIA_DRIVER_CAPABILITIES` | GPU capabilities | `compute,utility` |
|
||||
|
||||
### Docker Network Configuration
|
||||
|
||||
Services communicate via a custom Docker network (`profyt-net`) with static IP addressing:
|
||||
- Frontend: `192.168.42.20`
|
||||
- Backend: `192.168.42.22`
|
||||
|
||||
## Features
|
||||
|
||||
### Compliance Management
|
||||
- **ISO 27001** compliance tracking and reporting
|
||||
- **Records Management** integration workflows
|
||||
- **Risk Assessment** tools and dashboards
|
||||
- **Document Processing** with AI-powered analysis
|
||||
|
||||
### Workflow Engine
|
||||
- Multi-phase compliance workflows
|
||||
- Approval management system
|
||||
- Progress tracking and reporting
|
||||
- Integration with external systems
|
||||
|
||||
### AI-Powered Analysis
|
||||
- Document parsing and content extraction
|
||||
- Compliance gap analysis
|
||||
- Automated report generation
|
||||
- Natural language processing for policy analysis
|
||||
|
||||
## Deployment
|
||||
|
||||
### Production Deployment
|
||||
|
||||
On the **application host** (SSH), from the repository root:
|
||||
|
||||
1. **Secrets & config**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env: PUBLIC_HOST, ports, MinIO and Postgres credentials (openssl rand -base64 32).
|
||||
# Never commit `.env`. Postgres user/password apply only on FIRST empty DB volume — see `.env.example`.
|
||||
./scripts/verify-prod-env.sh
|
||||
```
|
||||
|
||||
2. **Deploy (pull, build, recreate containers)**
|
||||
```bash
|
||||
./scripts/deploy-prod.sh
|
||||
# Air-gapped / no registry pull:
|
||||
# ./scripts/deploy-prod.sh --no-pull
|
||||
```
|
||||
|
||||
Or manually (must pass `/.env` explicitly if it is not named `.env` next to the compose file):
|
||||
```bash
|
||||
docker compose --env-file .env -f docker-compose.prod.yml pull
|
||||
docker compose --env-file .env -f docker-compose.prod.yml up -d --build --remove-orphans
|
||||
```
|
||||
|
||||
3. **Smoke checks** (`FE_PORT` and API port come from `.env` / compose; API is `127.0.0.1:4402` in prod compose)
|
||||
```bash
|
||||
# Replace 8081 with the FE_PORT value in .env when different.
|
||||
curl -sf http://127.0.0.1:8081/
|
||||
curl -sf http://127.0.0.1:4402/health
|
||||
```
|
||||
|
||||
### Scaling Considerations
|
||||
|
||||
- **Frontend**: Stateless, horizontally scalable
|
||||
- **Backend**: Consider database persistence for production
|
||||
- **AI Models**: GPU requirements for optimal performance
|
||||
- **Storage**: Implement proper file storage for documents
|
||||
|
||||
## Monitoring and Logging
|
||||
|
||||
### Application Logs
|
||||
- Frontend logs: Available via Docker logs
|
||||
- Backend logs: Stored in `be0/logs/` directory
|
||||
- System logs: `docker-compose logs [service-name]`
|
||||
|
||||
### Health Monitoring
|
||||
- Health check endpoints available
|
||||
- Docker health checks configured
|
||||
- Log aggregation recommended for production
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Current Implementation
|
||||
- CORS enabled for cross-origin requests
|
||||
- Input validation via Pydantic models
|
||||
- File upload restrictions
|
||||
|
||||
### Production Recommendations
|
||||
- Implement authentication/authorization
|
||||
- Add rate limiting
|
||||
- Enable HTTPS/TLS
|
||||
- Implement proper secret management
|
||||
- Add audit logging
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
### Development Guidelines
|
||||
- Follow TypeScript best practices
|
||||
- Write comprehensive tests
|
||||
- Update documentation for new features
|
||||
- Follow conventional commit messages
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the terms specified in the LICENSE file.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copy to .env and adjust. docker-compose sets these for the be0 service when using the repo stack.
|
||||
INITIATIVE_DATABASE_URL=postgresql+asyncpg://initiative:initiative_secret@localhost:15432/initiatives
|
||||
|
||||
# S3 / MinIO — server-to-server (API → object store)
|
||||
S3_ENDPOINT_URL=http://localhost:19000
|
||||
S3_ACCESS_KEY=minio_user
|
||||
S3_SECRET_KEY=minio_password
|
||||
S3_BUCKET_ATTACHMENTS=initiative-attachments
|
||||
S3_BUCKET_EXPORTS=initiative-exports
|
||||
S3_BUCKET_QUARANTINE=initiative-quarantine
|
||||
|
||||
# Optional: HTTPS base for presigned URLs (must match public MinIO TLS host; see docs/minio-behind-https.md)
|
||||
# S3_PUBLIC_ENDPOINT_URL=https://minio-api.example.com
|
||||
|
||||
# Optional: comma-separated extra browser origins for CORS (merged with localhost defaults in main.py).
|
||||
# In Docker dev stack, docker-compose.yml can set this; production compose adds your public UI URL automatically.
|
||||
# CORS_ORIGINS=http://YOUR_LAN_IP:8081
|
||||
|
||||
# Local Python runs may load this file; Docker Compose uses the repo-root `.env` for ${SMTP_*} → be0.
|
||||
# Password reset email (same SMTP block as `.env.example` beside docker-compose for dev stack.)
|
||||
# OTP + reset use src/auth_mail.py: set SMTP_* for Option A or AUTH_MAIL_LOG_ONLY=1 locally.
|
||||
# AUTH_MAIL_LOG_ONLY=1
|
||||
# AUTH_PUBLIC_WEB_ORIGIN=http://localhost:8081
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=
|
||||
# SMTP_PASSWORD=
|
||||
# AUTH_MAIL_FROM=noreply@example.com
|
||||
# SMTP_USE_TLS=1
|
||||
@@ -0,0 +1,223 @@
|
||||
# Chat Assistant Module
|
||||
|
||||
## Overview
|
||||
|
||||
The Chat Assistant module provides a conversational AI interface for answering policy and compliance questions using Ollama.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend (`be0/src/chat_assistant.py`)
|
||||
|
||||
The `ChatAssistant` class provides:
|
||||
- **Chat functionality**: Conversational AI for policy questions
|
||||
- **Content verification**: Verify content against compliance requirements
|
||||
- **Policy Q&A**: Answer questions about policies and compliance
|
||||
|
||||
### Frontend (`fe0/src/features/chat/`)
|
||||
|
||||
The frontend chat feature includes:
|
||||
- **Service layer**: API communication with backend
|
||||
- **React hooks**: Easy-to-use hooks for chat functionality
|
||||
- **Type definitions**: TypeScript types for type safety
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. Chat Endpoint
|
||||
```
|
||||
POST /api/v1/chat
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"message": "What are ISO 27001 requirements?",
|
||||
"conversation_history": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Previous message"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Previous response"
|
||||
}
|
||||
],
|
||||
"context": "Optional context about policies"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "ISO 27001 is an information security management system...",
|
||||
"model": "gemma3:27b",
|
||||
"tokens_used": 150
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Verify Content Endpoint
|
||||
```
|
||||
POST /api/v1/chat/verify
|
||||
```
|
||||
|
||||
**Form Data:**
|
||||
- `field_name`: Name of the field being verified
|
||||
- `content`: Content to verify
|
||||
- `verification_criteria`: (Optional) Specific criteria to check
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "The content meets compliance requirements...",
|
||||
"model": "gemma3:27b",
|
||||
"tokens_used": 200
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Policy Question Endpoint
|
||||
```
|
||||
POST /api/v1/chat/question
|
||||
```
|
||||
|
||||
**Form Data:**
|
||||
- `question`: The user's question
|
||||
- `policy_context`: (Optional) Context about specific policies
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Answer to the policy question...",
|
||||
"model": "gemma3:27b",
|
||||
"tokens_used": 180
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Conversational Context
|
||||
- Maintains conversation history for context-aware responses
|
||||
- Keeps last 10 messages for context
|
||||
- System prompt guides the assistant's behavior
|
||||
|
||||
### 2. Policy Expertise
|
||||
- Specialized in IT governance and compliance
|
||||
- Knowledgeable about ISO 27001, NIST, GDPR, etc.
|
||||
- Provides accurate, actionable advice
|
||||
|
||||
### 3. Content Verification
|
||||
- Analyzes content against compliance requirements
|
||||
- Provides detailed feedback
|
||||
- Suggests improvements
|
||||
|
||||
## Usage
|
||||
|
||||
### Backend
|
||||
|
||||
```python
|
||||
from src.chat_assistant import get_chat_assistant
|
||||
|
||||
# Get chat assistant instance
|
||||
assistant = get_chat_assistant()
|
||||
|
||||
# Chat
|
||||
request = ChatRequest(
|
||||
message="What is ISO 27001?",
|
||||
context="IT governance"
|
||||
)
|
||||
response = await assistant.chat(request)
|
||||
|
||||
# Verify content
|
||||
response = await assistant.verify_content(
|
||||
field_name="Project Description",
|
||||
content="Our project implements security controls..."
|
||||
)
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```typescript
|
||||
import { useChat } from '@/features/chat/hooks/useChat';
|
||||
|
||||
const { sendMessage, verifyContent, isLoading } = useChat();
|
||||
|
||||
// Send a message
|
||||
const response = await sendMessage(
|
||||
"What are compliance requirements?",
|
||||
conversationHistory, // Optional
|
||||
"ISO 27001 context" // Optional
|
||||
);
|
||||
|
||||
// Verify content
|
||||
const verification = await verifyContent(
|
||||
"Project Name",
|
||||
"Project content to verify"
|
||||
);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Model Selection
|
||||
|
||||
The default model is `gemma3:27b`. To change it:
|
||||
|
||||
```python
|
||||
# In chat_assistant.py
|
||||
assistant = ChatAssistant(model_name="your-model-name")
|
||||
```
|
||||
|
||||
### System Prompt
|
||||
|
||||
The system prompt can be customized in the `ChatAssistant.__init__` method to change the assistant's behavior and expertise.
|
||||
|
||||
## Logging
|
||||
|
||||
All chat interactions are logged to:
|
||||
- `be0/logs/ChatAssistant.log`
|
||||
|
||||
This helps with debugging and monitoring.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The module includes comprehensive error handling:
|
||||
- Catches and logs all exceptions
|
||||
- Returns user-friendly error messages
|
||||
- Raises HTTPException for API errors
|
||||
|
||||
## Testing
|
||||
|
||||
To test the chat assistant:
|
||||
|
||||
1. **Start the backend:**
|
||||
```bash
|
||||
cd be0
|
||||
docker-compose up be0
|
||||
```
|
||||
|
||||
2. **Test via API:**
|
||||
```bash
|
||||
curl -X POST http://localhost:4402/api/v1/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "What is ISO 27001?"}'
|
||||
```
|
||||
|
||||
3. **Test via Frontend:**
|
||||
- Open the Dashboard
|
||||
- Use the ChatAssistant component
|
||||
- Ask questions or verify content
|
||||
|
||||
## Integration
|
||||
|
||||
The ChatAssistant is integrated with:
|
||||
- **ChatAssistant.tsx**: React component in the Dashboard
|
||||
- **useChat hook**: React hook for chat functionality
|
||||
- **chatService**: API service layer
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
1. Streaming responses for real-time text generation
|
||||
2. Multi-turn conversation management
|
||||
3. Document context injection
|
||||
4. Voice input/output
|
||||
5. Response rating and feedback
|
||||
6. Conversation export
|
||||
7. Custom model fine-tuning
|
||||
@@ -0,0 +1,34 @@
|
||||
FROM python:3.11
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the requirements file
|
||||
COPY ./requirements.txt /app/
|
||||
|
||||
# Install dependencies and set up Python environment
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
zstd \
|
||||
curl \
|
||||
git \
|
||||
build-essential \
|
||||
python3-pip \
|
||||
libreoffice-writer-nogui \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# RUN curl -fsSL https://ollama.com/install.sh | sh
|
||||
|
||||
|
||||
RUN pip install --upgrade pip
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install nltk
|
||||
# Avoid runtime GitHub downloads (slow/hanging in some networks) before Uvicorn starts.
|
||||
RUN python3 -m nltk.downloader punkt punkt_tab stopwords averaged_perceptron_tagger_eng wordnet
|
||||
|
||||
COPY . /app/
|
||||
|
||||
EXPOSE 4402
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -0,0 +1,172 @@
|
||||
# Governance Layer Status in be0
|
||||
|
||||
## Current State
|
||||
|
||||
### ✅ What EXISTS (Current Implementation)
|
||||
|
||||
The current `be0` codebase has:
|
||||
|
||||
1. **Basic Workflow System** (`src/domain/entities/workflow.py`, `src/application/services/workflow_service.py`)
|
||||
- SDLC/RM Integration workflow
|
||||
- Phase-based progression
|
||||
- Task/checklist management
|
||||
- **Location**: `be0/src/domain/entities/workflow.py`
|
||||
|
||||
2. **Compliance Verification** (`src/compliance_verifier.py`)
|
||||
- Ollama-based compliance checking
|
||||
- Text generation and similarity analysis
|
||||
- **Location**: `be0/src/compliance_verifier.py`
|
||||
|
||||
3. **Chat Assistant** (`src/chat_assistant.py`)
|
||||
- Policy Q&A functionality
|
||||
- Content verification
|
||||
- **Location**: `be0/src/chat_assistant.py`
|
||||
|
||||
4. **Architecture Foundation**
|
||||
- Domain/Application/Infrastructure layers
|
||||
- Repository pattern
|
||||
- API routes structure
|
||||
- **Location**: `be0/src/domain/`, `be0/src/application/`, `be0/src/api/`
|
||||
|
||||
---
|
||||
|
||||
## ❌ What's MISSING (Governance Layer for Initiatives)
|
||||
|
||||
The **Grassroots Initiative Recognition System** governance layer has **NOT been implemented yet**.
|
||||
|
||||
### Missing Components:
|
||||
|
||||
#### 1. **Initiative Management**
|
||||
- ❌ Initiative entity (initiative_id, group_type, status, etc.)
|
||||
- ❌ Author management (contribution percentages, lead author logic)
|
||||
- ❌ Unit/Appraisal Team entities
|
||||
- **Should be in**: `be0/src/domain/entities/initiative.py`
|
||||
|
||||
#### 2. **Business Rules Engine**
|
||||
- ❌ Novelty checker (duplicate detection)
|
||||
- ❌ Scoring algorithm (Group 01 dual/triple reviewer)
|
||||
- ❌ Auto-classification (Group 02)
|
||||
- ❌ Author contribution validator
|
||||
- **Should be in**: `be0/src/domain/rules/` or `be0/src/application/rules/`
|
||||
|
||||
#### 3. **Workflow State Machine**
|
||||
- ❌ Initiative state transitions (DRAFT → SUBMITTED → UNIT_REVIEW → etc.)
|
||||
- ❌ Deadline enforcement
|
||||
- ❌ SLA tracking
|
||||
- **Should be in**: `be0/src/application/state_machine.py` or `be0/src/domain/workflows/initiative_workflow.py`
|
||||
|
||||
#### 4. **Review Management**
|
||||
- ❌ Review assignment logic
|
||||
- ❌ Blind review enforcement
|
||||
- ❌ Score conflict detection
|
||||
- ❌ Reviewer assignment service
|
||||
- **Should be in**: `be0/src/application/services/review_service.py`
|
||||
|
||||
#### 5. **Document Management**
|
||||
- ❌ Form templates (Form 01, 03, 05, 06)
|
||||
- ❌ Document versioning
|
||||
- ❌ File storage integration
|
||||
- **Should be in**: `be0/src/infrastructure/storage/`
|
||||
|
||||
#### 6. **API Endpoints**
|
||||
- ❌ `/api/v1/initiatives` (CRUD)
|
||||
- ❌ `/api/v1/initiatives/{id}/submit`
|
||||
- ❌ `/api/v1/initiatives/{id}/reviews`
|
||||
- ❌ `/api/v1/reviews/{review_id}/score`
|
||||
- ❌ `/api/v1/initiatives/{id}/appeal`
|
||||
- **Should be in**: `be0/src/api/routes/initiatives.py`
|
||||
|
||||
---
|
||||
|
||||
## Recommended Structure for Governance Layer
|
||||
|
||||
```
|
||||
be0/src/
|
||||
├── domain/
|
||||
│ ├── entities/
|
||||
│ │ ├── initiative.py # ❌ MISSING
|
||||
│ │ ├── author.py # ❌ MISSING
|
||||
│ │ ├── review.py # ❌ MISSING
|
||||
│ │ ├── unit.py # ❌ MISSING
|
||||
│ │ └── appraisal_team.py # ❌ MISSING
|
||||
│ ├── rules/
|
||||
│ │ ├── novelty_checker.py # ❌ MISSING
|
||||
│ │ ├── scoring_engine.py # ❌ MISSING
|
||||
│ │ ├── duplicate_detector.py # ❌ MISSING
|
||||
│ │ └── classification_engine.py # ❌ MISSING
|
||||
│ └── workflows/
|
||||
│ └── initiative_workflow.py # ❌ MISSING
|
||||
├── application/
|
||||
│ ├── services/
|
||||
│ │ ├── initiative_service.py # ❌ MISSING
|
||||
│ │ ├── review_service.py # ❌ MISSING
|
||||
│ │ ├── notification_service.py # ❌ MISSING
|
||||
│ │ └── deadline_service.py # ❌ MISSING
|
||||
│ └── state_machine.py # ❌ MISSING
|
||||
├── infrastructure/
|
||||
│ ├── storage/
|
||||
│ │ └── file_storage.py # ❌ MISSING
|
||||
│ └── database/
|
||||
│ └── models.py # ❌ MISSING (SQLAlchemy models)
|
||||
└── api/
|
||||
└── routes/
|
||||
├── initiatives.py # ❌ MISSING
|
||||
├── reviews.py # ❌ MISSING
|
||||
└── reports.py # ❌ MISSING
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What to Build Next
|
||||
|
||||
Based on the simplified tech stack we discussed, here's the implementation order:
|
||||
|
||||
### Phase 1: Core Entities & Database
|
||||
1. Create database models (PostgreSQL)
|
||||
2. Create domain entities (Initiative, Author, Review, etc.)
|
||||
3. Create repository interfaces
|
||||
|
||||
### Phase 2: Business Rules
|
||||
1. Novelty checker (using PostgreSQL pg_trgm)
|
||||
2. Scoring engine
|
||||
3. Auto-classification logic
|
||||
|
||||
### Phase 3: Workflow
|
||||
1. State machine implementation
|
||||
2. Transition rules
|
||||
3. Deadline tracking
|
||||
|
||||
### Phase 4: API & Services
|
||||
1. Initiative service
|
||||
2. Review service
|
||||
3. API endpoints
|
||||
4. Document upload
|
||||
|
||||
---
|
||||
|
||||
## Current vs. Required
|
||||
|
||||
| Component | Current | Required | Status |
|
||||
|-----------|---------|----------|--------|
|
||||
| Workflow (SDLC) | ✅ | ✅ | Implemented |
|
||||
| Initiative Management | ❌ | ✅ | **Missing** |
|
||||
| Business Rules | ❌ | ✅ | **Missing** |
|
||||
| Review System | ❌ | ✅ | **Missing** |
|
||||
| State Machine | ❌ | ✅ | **Missing** |
|
||||
| Document Storage | ❌ | ✅ | **Missing** |
|
||||
| Scoring Engine | ❌ | ✅ | **Missing** |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
To implement the governance layer:
|
||||
|
||||
1. **Start with database schema** - Create PostgreSQL tables for initiatives, authors, reviews
|
||||
2. **Create domain entities** - Python classes for Initiative, Author, Review
|
||||
3. **Implement business rules** - Novelty checker, scoring engine
|
||||
4. **Build state machine** - Workflow transitions
|
||||
5. **Create API endpoints** - RESTful APIs for frontend
|
||||
6. **Add document storage** - Local filesystem integration
|
||||
|
||||
The foundation (layered architecture, FastAPI, PostgreSQL) is already in place - you just need to build the governance-specific components on top of it.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Chat Assistant Troubleshooting Guide
|
||||
|
||||
## Common Errors and Solutions
|
||||
|
||||
### Error: 500 Internal Server Error
|
||||
|
||||
This usually indicates one of the following issues:
|
||||
|
||||
#### 1. Ollama Not Running
|
||||
|
||||
**Symptoms:**
|
||||
- 500 error on `/api/v1/chat`
|
||||
- Error message mentions "connection" or "refused"
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check if Ollama is running in the container
|
||||
docker exec be0 ps aux | grep ollama
|
||||
|
||||
# If not running, restart the container
|
||||
docker-compose restart be0
|
||||
|
||||
# Or start Ollama manually
|
||||
docker exec be0 ollama serve &
|
||||
```
|
||||
|
||||
#### 2. Model Not Available
|
||||
|
||||
**Symptoms:**
|
||||
- Error mentions "model not found"
|
||||
- Model name mismatch
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check available models
|
||||
docker exec be0 ollama list
|
||||
|
||||
# Pull the required model
|
||||
docker exec be0 ollama pull gemma3:270M
|
||||
|
||||
# Verify model is available
|
||||
docker exec be0 ollama list | grep gemma3
|
||||
```
|
||||
|
||||
#### 3. Model Name Mismatch
|
||||
|
||||
**Issue:** Code uses `gemma3:27b` but entrypoint pulls `gemma3:270M`
|
||||
|
||||
**Solution:**
|
||||
The code has been updated to use `gemma3:270M` to match the entrypoint script.
|
||||
|
||||
#### 4. Network Connectivity
|
||||
|
||||
**Symptoms:**
|
||||
- Connection refused errors
|
||||
- Timeout errors
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check if Ollama is accessible from within the container
|
||||
docker exec be0 curl http://localhost:11434/api/tags
|
||||
|
||||
# Check Ollama service status
|
||||
docker exec be0 ollama list
|
||||
```
|
||||
|
||||
## Diagnostic Endpoints
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl http://localhost:4402/health
|
||||
```
|
||||
|
||||
This will show:
|
||||
- Overall service status
|
||||
- Ollama connection status
|
||||
- Available models
|
||||
|
||||
### Test Ollama Directly
|
||||
```bash
|
||||
# From inside the container
|
||||
docker exec be0 ollama run gemma3:270M "Hello"
|
||||
```
|
||||
|
||||
## Debugging Steps
|
||||
|
||||
1. **Check Backend Logs:**
|
||||
```bash
|
||||
docker-compose logs be0 | tail -50
|
||||
```
|
||||
|
||||
2. **Check Chat Assistant Logs:**
|
||||
```bash
|
||||
tail -f be0/logs/ChatAssistant.log
|
||||
```
|
||||
|
||||
3. **Test API Endpoint:**
|
||||
```bash
|
||||
curl -X POST http://localhost:4402/api/v1/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Hello"}'
|
||||
```
|
||||
|
||||
4. **Verify Ollama Service:**
|
||||
```bash
|
||||
docker exec be0 ollama list
|
||||
docker exec be0 curl http://localhost:11434/api/tags
|
||||
```
|
||||
|
||||
## Common Fixes
|
||||
|
||||
### Fix 1: Restart Ollama Service
|
||||
```bash
|
||||
docker exec be0 pkill ollama
|
||||
docker exec be0 ollama serve &
|
||||
sleep 2
|
||||
docker exec be0 ollama list
|
||||
```
|
||||
|
||||
### Fix 2: Pull Missing Model
|
||||
```bash
|
||||
docker exec be0 ollama pull gemma3:270M
|
||||
```
|
||||
|
||||
### Fix 3: Restart Container
|
||||
```bash
|
||||
docker-compose restart be0
|
||||
```
|
||||
|
||||
### Fix 4: Rebuild Container
|
||||
```bash
|
||||
docker-compose down
|
||||
docker-compose build be0
|
||||
docker-compose up be0
|
||||
```
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
When working correctly:
|
||||
1. Health endpoint shows Ollama as "connected"
|
||||
2. Available models list includes `gemma3:270M`
|
||||
3. Chat endpoint returns 200 with a response
|
||||
4. Logs show successful message processing
|
||||
|
||||
## Still Having Issues?
|
||||
|
||||
1. Check the full error in logs: `docker-compose logs be0`
|
||||
2. Verify Ollama is running: `docker exec be0 ps aux | grep ollama`
|
||||
3. Test Ollama directly: `docker exec be0 ollama run gemma3:270M "test"`
|
||||
4. Check model availability: `docker exec be0 ollama list`
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
|
||||
if command -v ollama >/dev/null 2>&1; then
|
||||
echo "Starting Ollama server..."
|
||||
ollama serve &
|
||||
sleep 1
|
||||
else
|
||||
echo "Ollama not installed in this image; skipping."
|
||||
fi
|
||||
|
||||
# if ! ollama list | grep -q "qwen2.5:3b"; then
|
||||
# echo "Model qwen2.5:3b not found. Pulling..."
|
||||
# ollama pull qwen2.5:3b
|
||||
|
||||
# else
|
||||
# echo "Model qwen2.5:3b already exists. Skipping pull."
|
||||
# fi
|
||||
|
||||
# #download embedding model
|
||||
# if ! ollama list | grep -q "embeddinggemma:300m"; then
|
||||
# echo "Model embeddinggemma:300m not found. Pulling..."
|
||||
# ollama pull embeddinggemma:300m
|
||||
|
||||
# else
|
||||
# echo "Model embeddinggemma:300m already exists. Skipping pull."
|
||||
# fi
|
||||
|
||||
# NLTK corpora are installed when the image is built (see Dockerfile).
|
||||
# Bind mount overwrites /app; image site-packages may be stale vs mounted requirements.txt.
|
||||
if [ -f /app/requirements.txt ]; then
|
||||
echo "Installing/updating Python deps from mounted /app/requirements.txt..."
|
||||
pip install --no-cache-dir -r /app/requirements.txt || {
|
||||
echo "ERROR: pip install -r /app/requirements.txt failed; fix deps and restart be0."
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
echo "Applying idempotent initiative DB migrations (008–014 incl. registration_otp_codes) if needed..."
|
||||
python /app/scripts/apply_initiative_migrations.py || echo "WARNING: apply_initiative_migrations exited non-zero — check be0 logs (API may return 503 for evidence/artifacts until DB is fixed)."
|
||||
|
||||
echo "Starting FastAPI..."
|
||||
if [ "${UVICORN_RELOAD:-0}" = "1" ]; then
|
||||
exec uvicorn main:app --host 0.0.0.0 --port 4402 --reload
|
||||
else
|
||||
exec uvicorn main:app --host 0.0.0.0 --port 4402
|
||||
fi
|
||||
+3726
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
# Test-only dependencies for CI (not installed in the runtime image).
|
||||
# be0 tests are a mix of unittest.TestCase (incl. IsolatedAsyncioTestCase) and
|
||||
# pytest-style; pytest runs both. pytest-asyncio covers the pytest async tests.
|
||||
-r requirements.txt
|
||||
pytest>=8,<9
|
||||
pytest-asyncio>=0.23,<0.24
|
||||
@@ -0,0 +1,43 @@
|
||||
uvicorn[standard]
|
||||
httpx
|
||||
sqlalchemy[asyncio]>=2.0
|
||||
asyncpg>=0.29
|
||||
greenlet>=3.0
|
||||
argon2-cffi>=23.1.0
|
||||
PyJWT>=2.8.0
|
||||
ollama
|
||||
fastapi
|
||||
asyncio
|
||||
python-multipart
|
||||
|
||||
langchain
|
||||
langchain-core
|
||||
langgraph
|
||||
|
||||
langchain-community
|
||||
sentence-transformers
|
||||
huggingface
|
||||
scikit-learn
|
||||
|
||||
neo4j
|
||||
|
||||
nltk
|
||||
rake-nltk
|
||||
pypdf
|
||||
pydantic
|
||||
pydantic-settings
|
||||
aioboto3
|
||||
zipstream-ng
|
||||
boto3
|
||||
numpy
|
||||
pandas
|
||||
|
||||
pyvi
|
||||
docling
|
||||
pymupdf
|
||||
docxtpl>=0.16
|
||||
openpyxl>=3.1.0
|
||||
|
||||
# ImageHub: best-effort imaging metadata sniff (DICOM / NIfTI). See src/imagehub_routes.py.
|
||||
pydicom
|
||||
nibabel
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Script to add the 10 UMP innovation ideas to the vector database
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.infrastructure.vector_db.qdrant_service import get_qdrant_service
|
||||
|
||||
UMP_IDEAS = [
|
||||
{
|
||||
"title": "Nền tảng Trợ lý AI học tập lâm sàng (Clinical AI Tutor)",
|
||||
"description": "Ứng dụng AI đóng vai trò trợ giảng cho sinh viên y, hỗ trợ phân tích ca bệnh giả lập, giải thích cận lâm sàng, và gợi ý chẩn đoán theo phác đồ Việt Nam.",
|
||||
"category": "Giáo dục - AI"
|
||||
},
|
||||
{
|
||||
"title": "Hệ thống bệnh án điện tử học thuật (Academic EMR Sandbox)",
|
||||
"description": "Môi trường EMR mô phỏng cho đào tạo và nghiên cứu, cho phép sinh viên và giảng viên thực hành nhập – phân tích – khai thác dữ liệu y khoa mà không ảnh hưởng dữ liệu bệnh nhân thật.",
|
||||
"category": "Giáo dục - Chuyển đổi số"
|
||||
},
|
||||
{
|
||||
"title": "Trung tâm mô phỏng y khoa bằng AR/VR & Digital Twin",
|
||||
"description": "Xây dựng phòng lab mô phỏng phẫu thuật, cấp cứu, và quy trình điều trị bằng AR/VR, kết hợp mô hình \"digital twin\" của cơ thể người phục vụ đào tạo nâng cao.",
|
||||
"category": "Giáo dục - AR/VR"
|
||||
},
|
||||
{
|
||||
"title": "Chương trình Y tế cộng đồng số cho vùng sâu vùng xa",
|
||||
"description": "Kết hợp telehealth, trợ lý ảo y tế (agentic care) và AI sàng lọc sớm bệnh không lây (NCD) cho người dân vùng nông thôn, miền núi và hải đảo.",
|
||||
"category": "Tác động xã hội - Telehealth"
|
||||
},
|
||||
{
|
||||
"title": "Nền tảng nghiên cứu AI y sinh dùng chung (UMP AI Research Hub)",
|
||||
"description": "Cung cấp hạ tầng GPU, kho dữ liệu y khoa ẩn danh, và công cụ phân tích AI cho giảng viên – nghiên cứu sinh – startup hợp tác nghiên cứu.",
|
||||
"category": "Nghiên cứu - AI"
|
||||
},
|
||||
{
|
||||
"title": "Hệ thống theo dõi và dự báo sức khỏe sinh viên & nhân viên y tế",
|
||||
"description": "Ứng dụng phân tích dữ liệu và AI để phát hiện sớm stress, burnout, và vấn đề sức khỏe tâm thần trong cộng đồng sinh viên và nhân viên y tế.",
|
||||
"category": "Tác động xã hội - Sức khỏe"
|
||||
},
|
||||
{
|
||||
"title": "Vườn ươm khởi nghiệp công nghệ y sinh (MedTech Incubator)",
|
||||
"description": "Hỗ trợ sinh viên, bác sĩ và giảng viên phát triển startup MedTech, HealthTech, AI y tế thông qua mentoring, quỹ seed và kết nối bệnh viện – doanh nghiệp.",
|
||||
"category": "Khởi nghiệp - MedTech"
|
||||
},
|
||||
{
|
||||
"title": "Hệ thống quản lý chất lượng đào tạo và kiểm định số",
|
||||
"description": "Số hóa toàn bộ quy trình đảm bảo chất lượng nội bộ (IQA), đánh giá chương trình đào tạo, và chuẩn hóa theo tiêu chuẩn quốc tế (WFME, AUN-QA).",
|
||||
"category": "Giáo dục - Quản lý chất lượng"
|
||||
},
|
||||
{
|
||||
"title": "Nền tảng dữ liệu lớn phòng chống dịch và bệnh không lây",
|
||||
"description": "Phân tích dữ liệu dịch tễ, môi trường, và hành vi để dự báo dịch bệnh, hỗ trợ Sở Y tế và Bộ Y tế trong ra quyết định chính sách.",
|
||||
"category": "Nghiên cứu - Dịch tễ học"
|
||||
},
|
||||
{
|
||||
"title": "Học viện Y học chính xác & Y học cá thể hóa",
|
||||
"description": "Kết hợp dữ liệu gen, hình ảnh y khoa, lối sống và AI để nghiên cứu và ứng dụng điều trị cá thể hóa cho bệnh ung thư, tim mạch và bệnh mạn tính.",
|
||||
"category": "Nghiên cứu - Y học chính xác"
|
||||
}
|
||||
]
|
||||
|
||||
async def main():
|
||||
"""Add all UMP ideas to the database"""
|
||||
print("Initializing Qdrant service...")
|
||||
qdrant_service = get_qdrant_service()
|
||||
|
||||
print("Initializing collection...")
|
||||
await qdrant_service.initialize_collection()
|
||||
|
||||
print(f"Adding {len(UMP_IDEAS)} ideas to the database...")
|
||||
results = []
|
||||
for i, idea in enumerate(UMP_IDEAS, 1):
|
||||
try:
|
||||
print(f"Adding idea {i}/{len(UMP_IDEAS)}: {idea['title']}")
|
||||
result = await qdrant_service.add_idea(
|
||||
title=idea['title'],
|
||||
description=idea['description'],
|
||||
category=idea['category']
|
||||
)
|
||||
results.append(result)
|
||||
print(f"✓ Added: {result['id']}")
|
||||
except Exception as e:
|
||||
print(f"✗ Error adding idea {i}: {e}")
|
||||
|
||||
print(f"\n✓ Successfully added {len(results)}/{len(UMP_IDEAS)} ideas")
|
||||
return results
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
# Apply migration 007 (user_roles.admin_from_email_policy) to an EXISTING Postgres.
|
||||
# initdb scripts in docker-entrypoint-initdb.d run only on first volume creation.
|
||||
#
|
||||
# Default (full SQL file): adds column, runs one-time policy DELETE/UPDATE (see
|
||||
# be0/migrations/007_user_roles_email_policy_admin.sql before running on prod).
|
||||
#
|
||||
# Usage (from anywhere):
|
||||
# ./be0/scripts/apply-migration-007.sh
|
||||
# ./be0/scripts/apply-migration-007.sh --schema-only # only ADD COLUMN (safest repeat)
|
||||
#
|
||||
# On a remote host (SSH to be0/docker host, repo or copy of migrations present):
|
||||
# export POSTGRES_CONTAINER=initiative-postgres POSTGRES_USER=initiative POSTGRES_DB=initiatives
|
||||
# ./be0/scripts/apply-migration-007.sh
|
||||
#
|
||||
# From repo root (wrapper):
|
||||
# ./scripts/apply-migration-007-postgres.sh
|
||||
set -euo pipefail
|
||||
|
||||
SCHEMA_ONLY=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--schema-only) SCHEMA_ONLY=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
BE0_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SQL_FULL="$BE0_ROOT/migrations/007_user_roles_email_policy_admin.sql"
|
||||
CONTAINER="${POSTGRES_CONTAINER:-initiative-postgres}"
|
||||
PGUSER="${POSTGRES_USER:-initiative}"
|
||||
PGDATABASE="${POSTGRES_DB:-initiatives}"
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "error: Docker is not reachable (is the daemon running?)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
|
||||
echo "error: container not found: $CONTAINER (set POSTGRES_CONTAINER)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" != "true" ]]; then
|
||||
echo "error: container is not running: $CONTAINER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apply_schema_only() {
|
||||
docker exec -i "$CONTAINER" psql -U "$PGUSER" -d "$PGDATABASE" -v ON_ERROR_STOP=1 <<'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).';
|
||||
SQL
|
||||
}
|
||||
|
||||
apply_full() {
|
||||
if [[ ! -f "$SQL_FULL" ]]; then
|
||||
echo "error: missing migration file: $SQL_FULL" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker exec -i "$CONTAINER" psql -U "$PGUSER" -d "$PGDATABASE" -v ON_ERROR_STOP=1 <"$SQL_FULL"
|
||||
}
|
||||
|
||||
verify_column() {
|
||||
local out
|
||||
out="$(docker exec "$CONTAINER" psql -U "$PGUSER" -d "$PGDATABASE" -tAc \
|
||||
"SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'user_roles' AND column_name = 'admin_from_email_policy'")"
|
||||
if [[ "${out//$'\r'/}" != "1" ]]; then
|
||||
echo "error: verification failed: column admin_from_email_policy missing on public.user_roles" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if (( SCHEMA_ONLY )); then
|
||||
echo "Applying schema only (ADD COLUMN + COMMENT) → $CONTAINER / $PGDATABASE"
|
||||
apply_schema_only
|
||||
else
|
||||
echo "Applying full 007_user_roles_email_policy_admin.sql → $CONTAINER / $PGDATABASE"
|
||||
apply_full
|
||||
fi
|
||||
|
||||
verify_column
|
||||
echo "ok: user_roles.admin_from_email_policy is present; admin register/login should work with current be0."
|
||||
@@ -0,0 +1,533 @@
|
||||
"""
|
||||
Apply idempotent SQL fixes when the DB volume predates newer migrations.
|
||||
|
||||
- ``008_audit_events.sql`` when ``audit_events`` is missing (older volumes never
|
||||
ran ``docker-entrypoint-initdb.d`` for new files).
|
||||
- ``009_backup_artifact_roles_storage_kind.sql`` when ``storage_kind`` is missing.
|
||||
- ``010_user_staff_profiles.sql`` + ``011_academic_titles_vn.sql`` when
|
||||
``academic_titles`` is missing (staff profile / register flow).
|
||||
- ``013_email_verification.sql`` when ``email_verification_tokens`` is missing.
|
||||
- ``014_registration_otp.sql`` when ``registration_otp_codes`` is missing.
|
||||
|
||||
Run automatically from entrypoint when ``INITIATIVE_DATABASE_URL`` is set.
|
||||
Standalone:
|
||||
|
||||
INITIATIVE_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dbname \\
|
||||
python scripts/apply_initiative_migrations.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _async_url_to_asyncpg_dsn(url: str) -> str:
|
||||
u = url.strip()
|
||||
if "+asyncpg" in u:
|
||||
u = u.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||
return u
|
||||
|
||||
|
||||
def _strip_sql_comments(text: str) -> str:
|
||||
lines: list[str] = []
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith("--"):
|
||||
continue
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _split_sql_statements(text: str) -> list[str]:
|
||||
"""Split on semicolons outside ``$$`` dollar-quoted blocks (008 uses ``DO $$``)."""
|
||||
statements: list[str] = []
|
||||
buf: list[str] = []
|
||||
i = 0
|
||||
n = len(text)
|
||||
in_dollar = False
|
||||
while i < n:
|
||||
if text.startswith("$$", i):
|
||||
in_dollar = not in_dollar
|
||||
buf.append("$$")
|
||||
i += 2
|
||||
continue
|
||||
ch = text[i]
|
||||
if ch == ";" and not in_dollar:
|
||||
stmt = "".join(buf).strip()
|
||||
if stmt:
|
||||
statements.append(stmt)
|
||||
buf = []
|
||||
i += 1
|
||||
continue
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
tail = "".join(buf).strip()
|
||||
if tail:
|
||||
statements.append(tail)
|
||||
return statements
|
||||
|
||||
|
||||
async def _needs_audit_events_migration(conn) -> bool:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'audit_events'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_backup_migration(conn) -> bool:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'application_artifacts'
|
||||
AND column_name = 'storage_kind'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_staff_profiles_migration(conn) -> bool:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'academic_titles'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_email_verification_migration(conn) -> bool:
|
||||
"""True when verification tokens table is missing (013 also adds users.email_verified)."""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'email_verification_tokens'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_registration_otp_migration(conn) -> bool:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'registration_otp_codes'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_document_templates_migration(conn) -> bool:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'document_templates'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_research_projects_migration(conn) -> bool:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'research_projects'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_imagehub_datasets_migration(conn) -> bool:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'imagehub_datasets'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_imagehub_segmentation_columns_migration(conn) -> bool:
|
||||
"""True when imagehub_dataset_files lacks the segmentation-link columns (018)."""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'imagehub_dataset_files'
|
||||
AND column_name = 'file_kind'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_cloud_import_migration(conn) -> bool:
|
||||
"""True when the cloud-import storage_methods table is absent (019)."""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'imagehub_storage_methods'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_imagehub_stages_migration(conn) -> bool:
|
||||
"""True when the dataset-stages table is absent (020)."""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'imagehub_dataset_stages'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_imagehub_tasks_migration(conn) -> bool:
|
||||
"""True when the per-file task-pipeline table is absent (021)."""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'imagehub_tasks'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_imagehub_task_annotations_migration(conn) -> bool:
|
||||
"""True when imagehub_tasks lacks the annotations column (022)."""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'imagehub_tasks'
|
||||
AND column_name = 'annotations'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_imagehub_members_migration(conn) -> bool:
|
||||
"""True when the dataset-membership table is absent (023)."""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'imagehub_dataset_members'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_imagehub_dataset_project_link_migration(conn) -> bool:
|
||||
"""True when imagehub_datasets.research_project_id is absent (024)."""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'imagehub_datasets'
|
||||
AND column_name = 'research_project_id'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_imagehub_review_events_migration(conn) -> bool:
|
||||
"""True when the task-review-events table is absent (025)."""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'imagehub_task_review_events'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_imagehub_folder_path_migration(conn) -> bool:
|
||||
"""True when imagehub_dataset_files.folder_path is absent (026)."""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'imagehub_dataset_files'
|
||||
AND column_name = 'folder_path'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _needs_imagehub_label_map_migration(conn) -> bool:
|
||||
"""True when imagehub_datasets.label_map is absent (027)."""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'imagehub_datasets'
|
||||
AND column_name = 'label_map'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return row is None
|
||||
|
||||
|
||||
async def _apply_sql_file(conn, path: Path, label: str) -> None:
|
||||
body = _strip_sql_comments(path.read_text(encoding="utf-8"))
|
||||
for stmt in _split_sql_statements(body):
|
||||
await conn.execute(stmt)
|
||||
print(f"apply_initiative_migrations: {label} applied.")
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
raw_url = (os.environ.get("INITIATIVE_DATABASE_URL") or "").strip()
|
||||
if not raw_url.lower().startswith("postgresql"):
|
||||
print("apply_initiative_migrations: no PostgreSQL URL; skipping.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
m008 = root / "migrations" / "008_audit_events.sql"
|
||||
m009 = root / "migrations" / "009_backup_artifact_roles_storage_kind.sql"
|
||||
m010 = root / "migrations" / "010_user_staff_profiles.sql"
|
||||
m011 = root / "migrations" / "011_academic_titles_vn.sql"
|
||||
for p in (m008, m009, m010, m011):
|
||||
if not p.is_file():
|
||||
print(f"apply_initiative_migrations: missing {p}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
import asyncpg
|
||||
|
||||
dsn = _async_url_to_asyncpg_dsn(raw_url)
|
||||
conn = await asyncpg.connect(dsn, timeout=60)
|
||||
try:
|
||||
if await _needs_audit_events_migration(conn):
|
||||
print("apply_initiative_migrations: applying 008_audit_events …")
|
||||
await _apply_sql_file(conn, m008, "008_audit_events")
|
||||
else:
|
||||
print("apply_initiative_migrations: audit_events present; OK.")
|
||||
|
||||
if await _needs_backup_migration(conn):
|
||||
print("apply_initiative_migrations: applying 009_backup_artifact_roles_storage_kind …")
|
||||
await _apply_sql_file(conn, m009, "009_backup_artifact_roles_storage_kind")
|
||||
else:
|
||||
print("apply_initiative_migrations: application_artifacts.storage_kind present; OK.")
|
||||
|
||||
if await _needs_staff_profiles_migration(conn):
|
||||
print("apply_initiative_migrations: applying 010_user_staff_profiles …")
|
||||
await _apply_sql_file(conn, m010, "010_user_staff_profiles")
|
||||
print("apply_initiative_migrations: applying 011_academic_titles_vn …")
|
||||
await _apply_sql_file(conn, m011, "011_academic_titles_vn")
|
||||
else:
|
||||
print("apply_initiative_migrations: academic_titles present; OK.")
|
||||
|
||||
m013 = root / "migrations" / "013_email_verification.sql"
|
||||
if not m013.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m013}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_email_verification_migration(conn):
|
||||
print("apply_initiative_migrations: applying 013_email_verification …")
|
||||
await _apply_sql_file(conn, m013, "013_email_verification")
|
||||
else:
|
||||
print("apply_initiative_migrations: email_verification_tokens present; OK.")
|
||||
|
||||
m014 = root / "migrations" / "014_registration_otp.sql"
|
||||
if not m014.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m014}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_registration_otp_migration(conn):
|
||||
print("apply_initiative_migrations: applying 014_registration_otp …")
|
||||
await _apply_sql_file(conn, m014, "014_registration_otp")
|
||||
else:
|
||||
print("apply_initiative_migrations: registration_otp_codes present; OK.")
|
||||
|
||||
m015 = root / "migrations" / "015_document_templates.sql"
|
||||
if not m015.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m015}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_document_templates_migration(conn):
|
||||
print("apply_initiative_migrations: applying 015_document_templates …")
|
||||
await _apply_sql_file(conn, m015, "015_document_templates")
|
||||
else:
|
||||
print("apply_initiative_migrations: document_templates present; OK.")
|
||||
|
||||
m016 = root / "migrations" / "016_research_projects.sql"
|
||||
if not m016.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m016}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_research_projects_migration(conn):
|
||||
print("apply_initiative_migrations: applying 016_research_projects …")
|
||||
await _apply_sql_file(conn, m016, "016_research_projects")
|
||||
else:
|
||||
print("apply_initiative_migrations: research_projects present; OK.")
|
||||
|
||||
m017 = root / "migrations" / "017_imagehub_datasets.sql"
|
||||
if not m017.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m017}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_imagehub_datasets_migration(conn):
|
||||
print("apply_initiative_migrations: applying 017_imagehub_datasets …")
|
||||
await _apply_sql_file(conn, m017, "017_imagehub_datasets")
|
||||
else:
|
||||
print("apply_initiative_migrations: imagehub_datasets present; OK.")
|
||||
|
||||
m018 = root / "migrations" / "018_imagehub_segmentation_links.sql"
|
||||
if not m018.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m018}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_imagehub_segmentation_columns_migration(conn):
|
||||
print("apply_initiative_migrations: applying 018_imagehub_segmentation_links …")
|
||||
await _apply_sql_file(conn, m018, "018_imagehub_segmentation_links")
|
||||
else:
|
||||
print("apply_initiative_migrations: imagehub_dataset_files.file_kind present; OK.")
|
||||
|
||||
m019 = root / "migrations" / "019_imagehub_cloud_import.sql"
|
||||
if not m019.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m019}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_cloud_import_migration(conn):
|
||||
print("apply_initiative_migrations: applying 019_imagehub_cloud_import …")
|
||||
await _apply_sql_file(conn, m019, "019_imagehub_cloud_import")
|
||||
else:
|
||||
print("apply_initiative_migrations: imagehub_storage_methods present; OK.")
|
||||
|
||||
m020 = root / "migrations" / "020_imagehub_dataset_stages.sql"
|
||||
if not m020.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m020}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_imagehub_stages_migration(conn):
|
||||
print("apply_initiative_migrations: applying 020_imagehub_dataset_stages …")
|
||||
await _apply_sql_file(conn, m020, "020_imagehub_dataset_stages")
|
||||
else:
|
||||
print("apply_initiative_migrations: imagehub_dataset_stages present; OK.")
|
||||
|
||||
m021 = root / "migrations" / "021_imagehub_task_pipeline.sql"
|
||||
if not m021.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m021}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_imagehub_tasks_migration(conn):
|
||||
print("apply_initiative_migrations: applying 021_imagehub_task_pipeline …")
|
||||
await _apply_sql_file(conn, m021, "021_imagehub_task_pipeline")
|
||||
else:
|
||||
print("apply_initiative_migrations: imagehub_tasks present; OK.")
|
||||
|
||||
m022 = root / "migrations" / "022_imagehub_task_annotations.sql"
|
||||
if not m022.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m022}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_imagehub_task_annotations_migration(conn):
|
||||
print("apply_initiative_migrations: applying 022_imagehub_task_annotations …")
|
||||
await _apply_sql_file(conn, m022, "022_imagehub_task_annotations")
|
||||
else:
|
||||
print("apply_initiative_migrations: imagehub_tasks.annotations present; OK.")
|
||||
|
||||
m023 = root / "migrations" / "023_imagehub_dataset_members.sql"
|
||||
if not m023.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m023}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_imagehub_members_migration(conn):
|
||||
print("apply_initiative_migrations: applying 023_imagehub_dataset_members …")
|
||||
await _apply_sql_file(conn, m023, "023_imagehub_dataset_members")
|
||||
else:
|
||||
print("apply_initiative_migrations: imagehub_dataset_members present; OK.")
|
||||
|
||||
m024 = root / "migrations" / "024_imagehub_dataset_project_link.sql"
|
||||
if not m024.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m024}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_imagehub_dataset_project_link_migration(conn):
|
||||
print("apply_initiative_migrations: applying 024_imagehub_dataset_project_link …")
|
||||
await _apply_sql_file(conn, m024, "024_imagehub_dataset_project_link")
|
||||
else:
|
||||
print("apply_initiative_migrations: imagehub_datasets.research_project_id present; OK.")
|
||||
|
||||
m025 = root / "migrations" / "025_imagehub_task_review_events.sql"
|
||||
if not m025.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m025}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_imagehub_review_events_migration(conn):
|
||||
print("apply_initiative_migrations: applying 025_imagehub_task_review_events …")
|
||||
await _apply_sql_file(conn, m025, "025_imagehub_task_review_events")
|
||||
else:
|
||||
print("apply_initiative_migrations: imagehub_task_review_events present; OK.")
|
||||
|
||||
m026 = root / "migrations" / "026_imagehub_file_folder_path.sql"
|
||||
if not m026.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m026}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_imagehub_folder_path_migration(conn):
|
||||
print("apply_initiative_migrations: applying 026_imagehub_file_folder_path …")
|
||||
await _apply_sql_file(conn, m026, "026_imagehub_file_folder_path")
|
||||
else:
|
||||
print("apply_initiative_migrations: imagehub_dataset_files.folder_path present; OK.")
|
||||
|
||||
m027 = root / "migrations" / "027_imagehub_dataset_label_map.sql"
|
||||
if not m027.is_file():
|
||||
print(f"apply_initiative_migrations: missing {m027}", file=sys.stderr)
|
||||
return 1
|
||||
if await _needs_imagehub_label_map_migration(conn):
|
||||
print("apply_initiative_migrations: applying 027_imagehub_dataset_label_map …")
|
||||
await _apply_sql_file(conn, m027, "027_imagehub_dataset_label_map")
|
||||
else:
|
||||
print("apply_initiative_migrations: imagehub_datasets.label_map present; OK.")
|
||||
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"apply_initiative_migrations: FAILED: {exc}", file=sys.stderr)
|
||||
if os.environ.get("INITIATIVE_DB_STRICT_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
|
||||
return 1
|
||||
return 0
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI: merge a mis-linked submission onto the real CASE-* initiative row and delete the orphan initiative.
|
||||
|
||||
Usage (dry-run — default, no writes):
|
||||
|
||||
cd be0
|
||||
export INITIATIVE_DATABASE_URL="postgresql+asyncpg://user:pass@host:5432/initiatives"
|
||||
python scripts/repair_split_submission.py --submission-id sub-d560fbb6f2944ec6
|
||||
|
||||
Apply (commits one transaction):
|
||||
|
||||
python scripts/repair_split_submission.py --submission-id sub-... --good-case CASE-YOURCODE --execute
|
||||
|
||||
Requires the same Postgres URL as the API (`INITIATIVE_DATABASE_URL` / `DATABASE_URL`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
|
||||
async def _main_async() -> int:
|
||||
p = argparse.ArgumentParser(description="Repair split submission / wrong initiative linkage.")
|
||||
p.add_argument(
|
||||
"--submission-id",
|
||||
required=True,
|
||||
help="submissionRecord.id (e.g. sub-d560fbb6f2944ec6)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--good-case",
|
||||
dest="good_case",
|
||||
default=None,
|
||||
help="Explicit CASE-* code for the autosave row (recommended if owner has multiple drafts)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--execute",
|
||||
action="store_true",
|
||||
help="Apply changes (otherwise dry-run only)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
os.environ.setdefault("INITIATIVE_DATABASE_URL", os.getenv("DATABASE_URL") or "")
|
||||
from src.initiative_db.engine import get_session, init_engine, is_postgres_enabled
|
||||
from src.initiative_db.repair_split_submission import repair_submission_cross_initiative_merge
|
||||
|
||||
if not is_postgres_enabled():
|
||||
print("Error: set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
await init_engine()
|
||||
|
||||
async with get_session() as session:
|
||||
report = await repair_submission_cross_initiative_merge(
|
||||
session,
|
||||
submission_record_id=args.submission_id.strip(),
|
||||
good_case_code_explicit=(args.good_case or "").strip() or None,
|
||||
dry_run=not args.execute,
|
||||
)
|
||||
|
||||
lines = [
|
||||
f"dry_run={report.dry_run}",
|
||||
f"submission_record_id={report.submission_record_id}",
|
||||
f"owner_id={report.owner_id or '(n/a)'}",
|
||||
f"bad_case={report.bad_case_code or '(n/a)'}",
|
||||
f"good_case={report.good_case_code or '(n/a)'}",
|
||||
]
|
||||
if report.skipped:
|
||||
lines.append(f"SKIPPED: {report.skipped}")
|
||||
lines.extend(report.actions)
|
||||
print("\n".join(lines))
|
||||
|
||||
if args.execute and report.skipped:
|
||||
return 3
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
raise SystemExit(asyncio.run(_main_async()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user