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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
+29
View 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
+223
View File
@@ -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
+34
View File
@@ -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"]
+172
View File
@@ -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.
+150
View File
@@ -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`
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+46
View File
@@ -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 (008014 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
View File
File diff suppressed because it is too large Load Diff
+251
View File
@@ -0,0 +1,251 @@
-- Initiative Recognition System — PostgreSQL schema (architecture_plan.md §4)
-- Table order respects FKs (units before users).
CREATE EXTENSION IF NOT EXISTS citext;
-- ========= ENUMS =========
DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('applicant','council_member','editor','admin','viewer');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE initiative_class AS ENUM ('technical','research','textbook');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE research_evidence AS ENUM ('international','domestic','poster');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE eval_level AS ENUM ('high','medium','low');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE submission_status AS ENUM ('draft','submitted','under_review','approved','rejected');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE recognition_tier AS ENUM ('excellent','good');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- ========= IDENTITY =========
CREATE TABLE IF NOT EXISTS units (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
parent_id UUID REFERENCES units(id),
address TEXT
);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email CITEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
full_name TEXT NOT NULL,
phone TEXT,
unit_id UUID REFERENCES units(id),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role user_role NOT NULL,
PRIMARY KEY (user_id, role)
);
-- System user for anonymous draft saves (no login yet)
INSERT INTO users (id, email, password_hash, full_name)
VALUES (
'00000000-0000-4000-8000-000000000001',
'system@draft.local',
'-',
'System (draft owner)'
)
ON CONFLICT (email) DO NOTHING;
-- ========= CASE / INITIATIVE ROOT =========
CREATE TABLE IF NOT EXISTS initiatives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_code TEXT UNIQUE NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id),
status submission_status NOT NULL DEFAULT 'draft',
recognition_tier recognition_tier,
submitted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_initiatives_owner_status ON initiatives(owner_id, status);
-- ========= DRAFT SNAPSHOTS =========
CREATE TABLE IF NOT EXISTS drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
draft_code TEXT UNIQUE NOT NULL,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
payload JSONB NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_drafts_initiative ON drafts(initiative_id);
-- ========= ĐƠN (APPLICATION) =========
CREATE TABLE IF NOT EXISTS applications (
initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE,
initiative_name TEXT NOT NULL,
investor_name TEXT,
application_field TEXT,
first_apply_date DATE,
initiative_classification initiative_class,
research_evidence_kind research_evidence,
international_journal_decl TEXT,
content_summary TEXT,
confidential_info TEXT,
conditions TEXT,
author_evaluation TEXT,
trial_evaluation TEXT,
submission_day SMALLINT,
submission_month SMALLINT,
submission_year SMALLINT,
honesty_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT chk_first_apply_window
CHECK (first_apply_date IS NULL
OR first_apply_date BETWEEN DATE '2025-04-15' AND DATE '2026-04-15')
);
CREATE TABLE IF NOT EXISTS authors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id),
ordinal SMALLINT NOT NULL,
full_name TEXT NOT NULL,
dob DATE,
workplace TEXT,
title TEXT,
qualification TEXT,
contribution_percent NUMERIC(5,2) NOT NULL,
is_representative BOOLEAN NOT NULL DEFAULT FALSE,
CHECK (contribution_percent >= 0 AND contribution_percent <= 100)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_authors_repr ON authors(initiative_id) WHERE is_representative;
CREATE TABLE IF NOT EXISTS support_staff (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
full_name TEXT,
dob DATE,
workplace TEXT,
title TEXT,
qualification TEXT,
support_content TEXT
);
CREATE TABLE IF NOT EXISTS evidence_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('textbook','research','technical')),
storage_uri TEXT NOT NULL,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/pdf',
byte_size BIGINT NOT NULL,
sha256 CHAR(64) NOT NULL,
uploaded_by UUID NOT NULL REFERENCES users(id),
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_kind ON evidence_files(initiative_id, kind);
-- ========= BÁO CÁO (REPORT) =========
CREATE TABLE IF NOT EXISTS reports (
initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE,
introduction TEXT,
representative_phone TEXT,
representative_email TEXT,
current_status TEXT,
purpose TEXT,
implementation_steps TEXT,
first_applied_unit TEXT,
achieved_result TEXT,
novelty TEXT,
effectiveness JSONB NOT NULL DEFAULT '{}'::jsonb,
submission_date DATE
);
CREATE TABLE IF NOT EXISTS trial_units (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
name TEXT NOT NULL,
address TEXT,
field TEXT,
ordinal SMALLINT
);
-- ========= CONTRIBUTION CONFIRMATION =========
CREATE TABLE IF NOT EXISTS contributions (
initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE,
main_author TEXT NOT NULL,
position TEXT,
representative_percent NUMERIC(5,2),
submission_date TIMESTAMPTZ,
digital_signature_confirmed BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS contribution_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
full_name TEXT,
work_unit TEXT,
contribution_percent NUMERIC(5,2)
);
-- ========= PHIẾU ĐÁNH GIÁ =========
CREATE TABLE IF NOT EXISTS evaluations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
council_member_id UUID NOT NULL REFERENCES users(id),
position TEXT,
evaluation_date DATE NOT NULL,
novelty_level eval_level,
novelty_score SMALLINT,
novelty_comment TEXT,
effectiveness_level eval_level,
effectiveness_score SMALLINT,
effectiveness_comment TEXT,
total_score SMALLINT GENERATED ALWAYS AS
(COALESCE(novelty_score,0) + COALESCE(effectiveness_score,0)) STORED,
conclusion TEXT,
status submission_status NOT NULL DEFAULT 'draft',
submitted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (novelty_score IS NULL OR (novelty_score BETWEEN 0 AND 40)),
CHECK (effectiveness_score IS NULL OR (effectiveness_score BETWEEN 0 AND 60)),
UNIQUE (initiative_id, council_member_id)
);
CREATE INDEX IF NOT EXISTS idx_eval_initiative ON evaluations(initiative_id);
-- ========= ADMIN VERIFY =========
CREATE TABLE IF NOT EXISTS verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
field_name TEXT NOT NULL,
content_hash CHAR(64) NOT NULL,
verified_by UUID NOT NULL REFERENCES users(id),
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
result TEXT
);
-- ========= AUDIT TRAIL =========
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
actor_id UUID REFERENCES users(id),
action TEXT NOT NULL,
entity TEXT NOT NULL,
entity_id UUID NOT NULL,
diff JSONB,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity, entity_id);
@@ -0,0 +1,71 @@
-- Versioned tab payloads + immutable submit snapshots + workflow/taxonomy + artifact registry.
-- Apply on existing DBs: psql "$INITIATIVE_DATABASE_URL" -f migrations/002_application_storage_extensions.sql
-- (use sync driver URL, not asyncpg, for psql)
-- ========= DRAFT TAB SNAPSHOTS (fe0: report | application | contribution) =========
CREATE TABLE IF NOT EXISTS draft_tab_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
draft_id UUID REFERENCES drafts(id) ON DELETE SET NULL,
tab TEXT NOT NULL CHECK (tab IN ('report', 'application', 'contribution')),
tab_version INTEGER NOT NULL DEFAULT 1,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
source TEXT NOT NULL DEFAULT 'autosave',
captured_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_draft_tab_snapshots_init_tab_ver
ON draft_tab_snapshots (initiative_id, tab, tab_version DESC);
CREATE INDEX IF NOT EXISTS idx_draft_tab_snapshots_captured
ON draft_tab_snapshots (captured_at DESC);
-- ========= SUBMIT SNAPSHOTS (immutable row per successful submit) =========
CREATE TABLE IF NOT EXISTS application_submit_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
submission_record_id TEXT NOT NULL,
merged_tabs JSONB NOT NULL DEFAULT '{}'::jsonb,
submit_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
full_pdf_uri TEXT NOT NULL,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_submit_snapshots_init_time
ON application_submit_snapshots (initiative_id, captured_at DESC);
-- ========= WORKFLOW / LIST PROJECTION (council fields) =========
CREATE TABLE IF NOT EXISTS application_workflow (
initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE,
review_status TEXT NOT NULL DEFAULT 'not_reviewed',
review_deadline DATE,
reviewer JSONB,
supervisor JSONB,
conference JSONB,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ========= TAXONOMY (subjectId, groupId, topicType from fe0 ApplicationItem) =========
CREATE TABLE IF NOT EXISTS application_taxonomy (
initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE,
subject_id TEXT NOT NULL DEFAULT '',
group_id TEXT NOT NULL DEFAULT '',
topic_type TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ========= ARTIFACTS (PDF + future abstract/poster URIs; complements evidence_files) =========
CREATE TABLE IF NOT EXISTS application_artifacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN (
'full_pdf', 'abstract', 'poster',
'textbook_evidence', 'research_evidence', 'technical_evidence', 'other'
)),
storage_uri TEXT NOT NULL,
original_name TEXT,
mime_type TEXT NOT NULL DEFAULT 'application/pdf',
byte_size BIGINT,
sha256 CHAR(64),
uploaded_by UUID REFERENCES users(id),
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (initiative_id, role)
);
CREATE INDEX IF NOT EXISTS idx_application_artifacts_init ON application_artifacts (initiative_id);
+22
View File
@@ -0,0 +1,22 @@
-- Persist ReviewPanel JSON bundles (templateData + officialBieuMau + full trees)
-- Apply on existing DBs:
-- psql "$INITIATIVE_DATABASE_URL" -f migrations/003_review_documents.sql
CREATE TABLE IF NOT EXISTS application_review_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
case_id TEXT NOT NULL,
document_version INTEGER NOT NULL DEFAULT 1,
official_bieu_mau JSONB NOT NULL DEFAULT '{}'::jsonb,
template_data JSONB,
full_bundle JSONB,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (initiative_id, document_version)
);
CREATE INDEX IF NOT EXISTS idx_review_docs_initiative_time
ON application_review_documents (initiative_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_review_docs_case_time
ON application_review_documents (case_id, created_at DESC);
@@ -0,0 +1,18 @@
-- Admin-recorded adjudication outcome per initiative (linked to applicant application id API).
-- One row per initiative; CRUD via /api/applications/{applicationId}/admin-result
CREATE TABLE IF NOT EXISTS application_admin_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
decision TEXT NOT NULL CHECK (decision IN ('approved','rejected')),
feedback TEXT NOT NULL DEFAULT '',
rationale TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id),
CONSTRAINT uq_application_admin_results_initiative UNIQUE (initiative_id)
);
CREATE INDEX IF NOT EXISTS idx_application_admin_results_initiative
ON application_admin_results(initiative_id);
@@ -0,0 +1,13 @@
-- Evidence staff review (approve / reject) on application_artifacts — must match be0/src/initiative_db/models.py ApplicationArtifact
-- New DBs: loaded by docker-compose postgres init (04_...).
-- Existing DBs: run once, e.g.
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/004_evidence_artifact_review.sql
-- # or: psql "$INITIATIVE_DATABASE_URL" -f be0/migrations/004_evidence_artifact_review.sql
ALTER TABLE application_artifacts
ADD COLUMN IF NOT EXISTS review_status TEXT,
ADD COLUMN IF NOT EXISTS reviewed_by UUID REFERENCES users (id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_application_artifacts_review
ON application_artifacts (initiative_id, review_status);
+26
View File
@@ -0,0 +1,26 @@
-- In-app notifications for applicants (admin adjudication → inbox).
-- Best-effort insert after PUT/POST admin-result; full text duplicated for read UX.
CREATE TABLE IF NOT EXISTS user_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipient_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('admin_application_decision')),
title TEXT NOT NULL,
body TEXT NOT NULL,
application_id TEXT NOT NULL,
related_initiative_id UUID REFERENCES initiatives(id) ON DELETE SET NULL,
source_admin_result_id UUID REFERENCES application_admin_results(id) ON DELETE SET NULL,
decision TEXT NOT NULL CHECK (decision IN ('approved','rejected')),
merit_category_label TEXT,
feedback_text TEXT NOT NULL DEFAULT '',
rationale_text TEXT,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS user_notifications_inbox_idx
ON user_notifications (recipient_user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS user_notifications_unread_idx
ON user_notifications (recipient_user_id)
WHERE read_at IS NULL;
@@ -0,0 +1,33 @@
-- Policy-sourced admin rows: safe to drop when email leaves AUTH_ADMIN_EMAILS (app reconciliation).
-- Apply on existing DBs: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/007_user_roles_email_policy_admin.sql
-- Fresh docker-compose init: add this file as docker-entrypoint-initdb.d/07_*.sql
ALTER TABLE user_roles ADD COLUMN IF NOT EXISTS admin_from_email_policy BOOLEAN NOT NULL DEFAULT FALSE;
COMMENT ON COLUMN user_roles.admin_from_email_policy IS
'TRUE when admin was granted by email allow-list (AUTH_ADMIN_EMAILS). Reconciliation may DELETE this row if the user email is no longer in the list. FALSE preserves manually granted admin (future / exceptional).';
-- One-time cleanup: remove admin for addresses not in the default institutional allow-list
-- (must match default in auth_api._DEFAULT_POLICY_ADMIN_EMAILS when AUTH_ADMIN_EMAILS is unset).
DELETE FROM user_roles ur
USING users u
WHERE ur.user_id = u.id
AND ur.role::text = 'admin'
AND lower(u.email::text) NOT IN (
'thaontt@ump.edu.vn',
'nltanh@ump.edu.vn',
'ldbaochau@ump.edu.vn',
'htchuong@ump.edu.vn'
);
UPDATE user_roles ur
SET admin_from_email_policy = TRUE
FROM users u
WHERE ur.user_id = u.id
AND ur.role::text = 'admin'
AND lower(u.email::text) IN (
'thaontt@ump.edu.vn',
'nltanh@ump.edu.vn',
'ldbaochau@ump.edu.vn',
'htchuong@ump.edu.vn'
);
+38
View File
@@ -0,0 +1,38 @@
-- Unified append-only audit trail (see assets/docs/audit-log-implementation.md).
-- Application role should be granted INSERT, SELECT only (configure per deployment).
DO $$
BEGIN
CREATE TYPE audit_action AS ENUM (
'create',
'read',
'update',
'delete',
'login',
'logout',
'login_failed'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END
$$;
CREATE TABLE IF NOT EXISTS audit_events (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
actor_email TEXT NOT NULL,
actor_role TEXT NOT NULL,
action audit_action NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
before JSONB,
after JSONB,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
request_id UUID
);
CREATE INDEX IF NOT EXISTS idx_audit_actor_time ON audit_events (actor_user_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_events (entity_type, entity_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_action_time ON audit_events (action, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_metadata_gin ON audit_events USING gin (metadata);
@@ -0,0 +1,35 @@
-- Backup / canonical storage: official printable DOCX+PDF roles + explicit storage_kind.
-- Apply: psql "$INITIATIVE_DATABASE_URL" -f migrations/009_backup_artifact_roles_storage_kind.sql
ALTER TABLE application_artifacts DROP CONSTRAINT IF EXISTS application_artifacts_role_check;
ALTER TABLE application_artifacts ADD CONSTRAINT application_artifacts_role_check CHECK (role IN (
'full_pdf',
'abstract',
'poster',
'textbook_evidence',
'research_evidence',
'technical_evidence',
'other',
'official_form_docx',
'official_form_pdf'
));
ALTER TABLE application_artifacts
ADD COLUMN IF NOT EXISTS storage_kind TEXT;
UPDATE application_artifacts SET storage_kind = CASE
WHEN storage_uri LIKE 'http://%' OR storage_uri LIKE 'https://%' THEN 'external_url'
WHEN storage_uri LIKE '/submitted-initiatives/%' THEN 'filesystem'
WHEN role IN ('research_evidence', 'textbook_evidence', 'technical_evidence') THEN 'minio_attachments'
ELSE 'minio_exports'
END
WHERE storage_kind IS NULL;
ALTER TABLE application_artifacts DROP CONSTRAINT IF EXISTS application_artifacts_storage_kind_check;
ALTER TABLE application_artifacts ADD CONSTRAINT application_artifacts_storage_kind_check
CHECK (storage_kind IS NULL OR storage_kind IN (
'minio_exports',
'minio_attachments',
'filesystem',
'external_url'
));
+114
View File
@@ -0,0 +1,114 @@
-- User staff profiles (1:1 with users) — HR / verification workflow
-- Apply: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/010_user_staff_profiles.sql
DO $$ BEGIN
CREATE TYPE profile_verification_status AS ENUM ('draft', 'pending', 'verified', 'rejected');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS academic_titles (
code TEXT PRIMARY KEY,
label_vi TEXT NOT NULL,
label_en TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE
);
INSERT INTO academic_titles (code, label_vi, label_en, sort_order) VALUES
('professor', 'Giáo sư', 'Professor', 10),
('associate_professor', 'Phó Giáo sư', 'Associate Professor', 20),
('doctor_sc', 'Tiến sĩ', 'Doctor of Science', 30),
('bsckii', 'BSCKII', 'Specialist level II', 35),
('bscki', 'BSCKI', 'Specialist level I', 36),
('master', 'Thạc sĩ', 'Master', 40),
('doctor_md', 'Bác sĩ', 'Physician', 45),
('pharmacist', 'Dược sĩ', 'Pharmacist', 46),
('bachelor', 'Cử nhân', 'Bachelor', 50),
('other', 'Khác (ghi rõ)', 'Other (specify)', 100)
ON CONFLICT (code) DO NOTHING;
CREATE TABLE IF NOT EXISTS user_staff_profiles (
user_id UUID PRIMARY KEY
REFERENCES users(id) ON DELETE CASCADE,
employee_id TEXT,
academic_title_code TEXT REFERENCES academic_titles(code),
academic_title_other TEXT,
unit_name_freetext TEXT,
job_title TEXT,
profile_verification_status profile_verification_status
NOT NULL DEFAULT 'draft',
verification_submitted_at TIMESTAMPTZ,
verified_at TIMESTAMPTZ,
verified_by_user_id UUID REFERENCES users(id),
rejection_reason TEXT,
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT employee_id_shape
CHECK (employee_id IS NULL OR employee_id ~ '^[A-Z0-9-]{3,32}$'),
CONSTRAINT academic_title_other_invariant CHECK (
CASE
WHEN academic_title_code IS NULL THEN academic_title_other IS NULL
WHEN academic_title_code = 'other' THEN
academic_title_other IS NOT NULL AND length(trim(academic_title_other)) > 0
ELSE academic_title_other IS NULL
END
),
CONSTRAINT verified_requires_metadata CHECK (
profile_verification_status <> 'verified'
OR (verified_at IS NOT NULL AND verified_by_user_id IS NOT NULL)
),
CONSTRAINT rejected_requires_reason CHECK (
profile_verification_status <> 'rejected'
OR (rejection_reason IS NOT NULL AND length(trim(rejection_reason)) > 0)
),
CONSTRAINT non_terminal_clears_verification CHECK (
profile_verification_status NOT IN ('draft', 'pending')
OR (verified_at IS NULL AND verified_by_user_id IS NULL)
),
CONSTRAINT rejected_clears_verification_metadata CHECK (
profile_verification_status <> 'rejected'
OR (verified_at IS NULL AND verified_by_user_id IS NULL)
),
CONSTRAINT verified_clears_rejection CHECK (
profile_verification_status <> 'verified'
OR rejection_reason IS NULL
),
CONSTRAINT job_title_length CHECK (
job_title IS NULL OR length(job_title) <= 120
)
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_usp_employee_id_unique
ON user_staff_profiles (employee_id)
WHERE employee_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_usp_pending_queue
ON user_staff_profiles (verification_submitted_at)
WHERE profile_verification_status = 'pending';
CREATE INDEX IF NOT EXISTS ix_usp_verifier_activity
ON user_staff_profiles (verified_by_user_id, verified_at DESC)
WHERE verified_by_user_id IS NOT NULL;
-- Backfill one row per existing user (draft, NULL fields)
INSERT INTO user_staff_profiles (user_id, profile_verification_status)
SELECT u.id, 'draft'::profile_verification_status
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM user_staff_profiles p WHERE p.user_id = u.id
);
COMMENT ON TABLE user_staff_profiles IS
'Institutional staff profile and verification state; scalars only — no MinIO.';
+19
View File
@@ -0,0 +1,19 @@
-- Extend / refresh academic_titles for UMP staff profile dropdown (VN labels + BSCK codes).
-- Apply after 010: psql … -f be0/migrations/011_academic_titles_vn.sql
INSERT INTO academic_titles (code, label_vi, label_en, sort_order, active) VALUES
('professor', 'Giáo sư', 'Professor', 10, TRUE),
('associate_professor', 'Phó Giáo sư', 'Associate Professor', 20, TRUE),
('doctor_sc', 'Tiến sĩ', 'Doctor of Science', 30, TRUE),
('bsckii', 'BSCKII', 'Specialist level II', 35, TRUE),
('bscki', 'BSCKI', 'Specialist level I', 36, TRUE),
('master', 'Thạc sĩ', 'Master', 40, TRUE),
('doctor_md', 'Bác sĩ', 'Physician', 45, TRUE),
('pharmacist', 'Dược sĩ', 'Pharmacist', 46, TRUE),
('bachelor', 'Cử nhân', 'Bachelor', 50, TRUE),
('other', 'Khác (ghi rõ)', 'Other (specify)', 100, TRUE)
ON CONFLICT (code) DO UPDATE SET
label_vi = EXCLUDED.label_vi,
label_en = EXCLUDED.label_en,
sort_order = EXCLUDED.sort_order,
active = EXCLUDED.active;
+19
View File
@@ -0,0 +1,19 @@
-- Password reset tokens + JWT credential invalidation (see auth_api, auth_credential_middleware).
-- Apply: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/012_password_reset.sql
ALTER TABLE users ADD COLUMN IF NOT EXISTS credential_version INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN users.credential_version IS
'Incremented on password change/reset. JWT ''cv'' claim must match or token is rejected.';
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at ON password_reset_tokens(expires_at);
+21
View File
@@ -0,0 +1,21 @@
-- Email verification before login (see auth_api deliver_email_verification_email).
-- Apply: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/013_email_verification.sql
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users SET email_verified = TRUE WHERE email_verified = FALSE;
COMMENT ON COLUMN users.email_verified IS
'FALSE until user confirms institutional inbox via email link; login and API tokens require TRUE.';
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user_id ON email_verification_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens(expires_at);
+20
View File
@@ -0,0 +1,20 @@
-- Registration email verification via 6-digit OTP (replaces magic-link issuance on register).
-- Apply after 013_email_verification.sql:
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/014_registration_otp.sql
CREATE TABLE IF NOT EXISTS registration_otp_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
otp_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
failed_attempts INT NOT NULL DEFAULT 0,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_registration_otp_codes_user_pending
ON registration_otp_codes (user_id)
WHERE used_at IS NULL;
COMMENT ON TABLE registration_otp_codes IS
'Hashed 6-digit OTP for register verification; pending rows deleted when superseded by resend.';
+24
View File
@@ -0,0 +1,24 @@
-- Admin-managed document templates: a .docx (stored in MinIO bucket initiative-templates)
-- plus its extracted Jinja placeholder fields. Applicants render a filled PDF by template id.
-- Apply after 014_registration_otp.sql:
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/015_document_templates.sql
CREATE TABLE IF NOT EXISTS document_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
storage_key TEXT NOT NULL,
original_filename TEXT,
content_sha256 TEXT,
fields JSONB NOT NULL DEFAULT '[]'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_document_templates_active
ON document_templates (is_active, created_at DESC);
COMMENT ON TABLE document_templates IS
'Admin-managed DOCX templates (file in MinIO initiative-templates) with extracted Jinja placeholder fields. Applicants render filled PDFs by template id.';
+133
View File
@@ -0,0 +1,133 @@
-- Research-project proposals (Thuyết minh đề tài, Mẫu III.06-TM.ĐTUD) + the PI "cockpit" entities.
-- A proposal row IS the project across its lifecycle: draft -> submitted -> approved | rejected.
-- On approval the cockpit unlocks; child tables (members/datasets/models/assets/milestones) hang off it.
-- Owner+admin authz (v1): a project is owned by owner_user_id; admins may review/approve/reject.
-- Apply after 015_document_templates.sql:
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/016_research_projects.sql
CREATE TABLE IF NOT EXISTS research_projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','submitted','approved','rejected')),
code TEXT,
title TEXT NOT NULL DEFAULT '',
level TEXT NOT NULL DEFAULT '',
pi_name TEXT NOT NULL DEFAULT '',
period_months INTEGER,
budget_total NUMERIC(14,2),
content JSONB NOT NULL DEFAULT '{}'::jsonb,
submitted_at TIMESTAMPTZ,
reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL,
reviewed_at TIMESTAMPTZ,
review_note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_research_projects_owner ON research_projects (owner_user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_research_projects_status ON research_projects (status, created_at DESC);
CREATE TABLE IF NOT EXISTS research_project_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
access TEXT NOT NULL DEFAULT '',
org TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
months INTEGER,
tasks TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_research_project_members_project ON research_project_members (project_id, sort_order);
CREATE TABLE IF NOT EXISTS research_project_datasets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
records INTEGER,
source TEXT NOT NULL DEFAULT '',
sensitivity TEXT NOT NULL DEFAULT '',
ethics TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_research_project_datasets_project ON research_project_datasets (project_id, sort_order);
CREATE TABLE IF NOT EXISTS research_project_models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
task TEXT NOT NULL DEFAULT '',
framework TEXT NOT NULL DEFAULT '',
version TEXT NOT NULL DEFAULT '',
dataset TEXT NOT NULL DEFAULT '',
auc NUMERIC(6,4),
sensitivity NUMERIC(6,4),
specificity NUMERIC(6,4),
accuracy NUMERIC(6,4),
owner TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_research_project_models_project ON research_project_models (project_id, sort_order);
CREATE TABLE IF NOT EXISTS research_project_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
acquisition TEXT NOT NULL DEFAULT '',
value NUMERIC(14,2),
owner TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_research_project_assets_project ON research_project_assets (project_id, sort_order);
CREATE TABLE IF NOT EXISTS research_project_milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL DEFAULT '',
deliverable TEXT NOT NULL DEFAULT '',
start_period TEXT NOT NULL DEFAULT '',
end_period TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '',
budget NUMERIC(14,2),
progress INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_research_project_milestones_project ON research_project_milestones (project_id, sort_order);
CREATE TABLE IF NOT EXISTS research_project_audit (
id BIGSERIAL PRIMARY KEY,
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
actor_name TEXT NOT NULL DEFAULT '',
role_label TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
subject TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_research_project_audit_project ON research_project_audit (project_id, occurred_at DESC);
COMMENT ON TABLE research_projects IS
'Research-project proposals (Thuyet minh de tai) that become managed projects on approval. Owner and admin authz. Content JSONB holds the full proposal form. Child research_project_* tables hold cockpit entities.';
+76
View File
@@ -0,0 +1,76 @@
-- ImageHub: content-addressed imaging dataset versioning (milestone 1 walking skeleton).
-- A dataset is owned by a user (investigator/PI). Files are stored as content-addressed,
-- globally deduped blobs in MinIO (one imagehub_blobs row per distinct sha256). The current
-- working file set lives in imagehub_dataset_files; a version freezes a manifest snapshot.
-- Admin sees all datasets (clinical data repository); owners see their own (research data).
-- Apply after 016_research_projects.sql:
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/017_imagehub_datasets.sql
CREATE TABLE IF NOT EXISTS imagehub_datasets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
slug TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private','internal','public')),
modality_tags JSONB NOT NULL DEFAULT '[]'::jsonb,
default_branch TEXT NOT NULL DEFAULT 'main',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_imagehub_datasets_owner ON imagehub_datasets (owner_user_id, created_at DESC);
-- Globally content-addressed blob registry: identical bytes across datasets dedupe to one row.
CREATE TABLE IF NOT EXISTS imagehub_blobs (
sha256 TEXT PRIMARY KEY,
size_bytes BIGINT NOT NULL DEFAULT 0,
media_type TEXT NOT NULL DEFAULT 'application/octet-stream',
storage_bucket TEXT NOT NULL DEFAULT '',
storage_key TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Current working file set on a dataset default branch (one row per logical path).
CREATE TABLE IF NOT EXISTS imagehub_dataset_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
logical_path TEXT NOT NULL DEFAULT '',
blob_sha256 TEXT NOT NULL REFERENCES imagehub_blobs(sha256) ON DELETE RESTRICT,
size_bytes BIGINT NOT NULL DEFAULT 0,
media_type TEXT NOT NULL DEFAULT 'application/octet-stream',
imaging_meta JSONB NOT NULL DEFAULT '{}'::jsonb,
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_dataset_files_path ON imagehub_dataset_files (dataset_id, logical_path);
-- Frozen version snapshots (the versioning spine; DAG-ready via parent_version_id).
CREATE TABLE IF NOT EXISTS imagehub_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
seq INTEGER NOT NULL DEFAULT 1,
message TEXT NOT NULL DEFAULT '',
manifest JSONB NOT NULL DEFAULT '[]'::jsonb,
parent_version_id UUID REFERENCES imagehub_versions(id) ON DELETE SET NULL,
author_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_versions_seq ON imagehub_versions (dataset_id, seq);
-- Append-only audit trail per dataset.
CREATE TABLE IF NOT EXISTS imagehub_dataset_audit (
id BIGSERIAL PRIMARY KEY,
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
actor_name TEXT NOT NULL DEFAULT '',
role_label TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
subject TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_audit_dataset ON imagehub_dataset_audit (dataset_id, occurred_at DESC);
COMMENT ON TABLE imagehub_datasets IS
'ImageHub content-addressed imaging datasets. Owner and admin authz. Files dedupe into imagehub_blobs by sha256 — imagehub_versions freezes a manifest snapshot.';
@@ -0,0 +1,21 @@
-- ImageHub: link organ-segmentation masks to their parent image file (Phase D).
-- A mask file (file_kind='segmentation') points at the image it segments via a
-- self-referential parent_file_id (e.g. an organ mask of ct.nii.gz); organ_label
-- names the organ. Regular files stay file_kind='image'. Idempotent (ADD COLUMN IF
-- NOT EXISTS) so the startup runner can apply it to volumes that predate it.
-- Apply after 017_imagehub_datasets.sql (no semicolons inside comments — the runner
-- splitter is naive):
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/018_imagehub_segmentation_links.sql
ALTER TABLE imagehub_dataset_files
ADD COLUMN IF NOT EXISTS file_kind TEXT NOT NULL DEFAULT 'image' CHECK (file_kind IN ('image','segmentation'));
ALTER TABLE imagehub_dataset_files
ADD COLUMN IF NOT EXISTS parent_file_id UUID REFERENCES imagehub_dataset_files(id) ON DELETE CASCADE;
ALTER TABLE imagehub_dataset_files
ADD COLUMN IF NOT EXISTS organ_label TEXT NOT NULL DEFAULT '';
-- List all masks of an image efficiently.
CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_files_parent
ON imagehub_dataset_files (parent_file_id);
@@ -0,0 +1,53 @@
-- ImageHub: Cloud Import — storage methods + external (referenced, not copied) dataset files.
-- A storage method holds verified credentials (config_encrypted, never returned to the client)
-- for an external bucket (S3/GCS/Azure). A dataset file is then EITHER a local content-addressed
-- blob (blob_sha256 set) OR an external reference (storage_method_id + external_path set) that
-- streams from the bucket and is never copied to our servers (privacy rule C4). Idempotent
-- (CREATE/ADD ... IF NOT EXISTS) so the startup runner can apply it to volumes that predate it.
-- Apply after 018 (no semicolons inside comments or string literals — the runner splitter is naive):
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/019_imagehub_cloud_import.sql
CREATE TABLE IF NOT EXISTS imagehub_storage_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
provider TEXT NOT NULL CHECK (provider IN ('s3','gcs','azure')),
access_mode TEXT NOT NULL DEFAULT 'read' CHECK (access_mode IN ('read','readwrite')),
bucket TEXT NOT NULL,
region TEXT,
config_encrypted TEXT NOT NULL,
verification_status TEXT NOT NULL DEFAULT 'pending' CHECK (verification_status IN ('pending','verified','failed')),
verification_reason TEXT,
verification_checked_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_imagehub_storage_methods_owner
ON imagehub_storage_methods (owner_id);
-- Allow a dataset file to be an external reference instead of a local blob. Existing rows keep
-- blob_sha256 set and the new columns NULL, so they satisfy the local-blob branch of the CHECK.
ALTER TABLE imagehub_dataset_files
ALTER COLUMN blob_sha256 DROP NOT NULL;
ALTER TABLE imagehub_dataset_files
ADD COLUMN IF NOT EXISTS storage_method_id UUID REFERENCES imagehub_storage_methods(id) ON DELETE RESTRICT;
ALTER TABLE imagehub_dataset_files
ADD COLUMN IF NOT EXISTS external_path TEXT;
-- A file is EITHER a local content-addressed blob OR an external reference, never both or neither.
ALTER TABLE imagehub_dataset_files
DROP CONSTRAINT IF EXISTS ck_imagehub_file_storage_mode;
ALTER TABLE imagehub_dataset_files
ADD CONSTRAINT ck_imagehub_file_storage_mode CHECK (
(blob_sha256 IS NOT NULL AND storage_method_id IS NULL AND external_path IS NULL)
OR
(blob_sha256 IS NULL AND storage_method_id IS NOT NULL AND external_path IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_files_storage_method
ON imagehub_dataset_files (storage_method_id);
@@ -0,0 +1,26 @@
-- ImageHub: labeling-pipeline stages on a dataset (Label -> Review_1 -> Review_2 ...). Each stage
-- has a kind (label/review), an order (seq), an optional review_percent (review stages only), and
-- an auto_assign flag (the "Automatic Task Assignment" toggle). Idempotent (CREATE ... IF NOT
-- EXISTS) so the startup runner can apply it to volumes that predate it. Apply after 019 (no
-- semicolons inside comments or string literals — the runner splitter is naive):
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/020_imagehub_dataset_stages.sql
CREATE TABLE IF NOT EXISTS imagehub_dataset_stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'label' CHECK (kind IN ('label','review')),
seq INTEGER NOT NULL DEFAULT 0,
review_percent INTEGER CHECK (review_percent IS NULL OR (review_percent >= 0 AND review_percent <= 100)),
auto_assign BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Stages of a dataset, in pipeline order.
CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_stages_dataset
ON imagehub_dataset_stages (dataset_id, seq);
-- A stage name is unique within its dataset.
CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_dataset_stages_name
ON imagehub_dataset_stages (dataset_id, name);
@@ -0,0 +1,37 @@
-- ImageHub: per-file work TASKS that flow through a dataset's pipeline stages (single-user MVP).
-- A task is a NEW join row (one per dataset file) carrying its pipeline position (current_stage_id
-- + pipeline_state), per-user queue status, assignee, priority, and the Ground-Truth reference flag.
-- The file row itself (imagehub_dataset_files) stays a pure storage record. Membership / multi-labeler
-- assignment is a later phase, so for now task access reuses the dataset owner-or-admin gate.
-- Idempotent (CREATE ... IF NOT EXISTS) so the startup runner can apply it to volumes that predate it.
-- Apply after 020 (no semicolons inside comments or string literals — the runner splitter is naive):
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/021_imagehub_task_pipeline.sql
CREATE TABLE IF NOT EXISTS imagehub_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
dataset_file_id UUID NOT NULL REFERENCES imagehub_dataset_files(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
current_stage_id UUID REFERENCES imagehub_dataset_stages(id) ON DELETE SET NULL,
pipeline_state TEXT NOT NULL DEFAULT 'inLabel' CHECK (pipeline_state IN ('inLabel','inReview','groundTruth','issue')),
queue_status TEXT NOT NULL DEFAULT 'assigned' CHECK (queue_status IN ('assigned','saved','pendingFinalization','skipped')),
assignee_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
assignment_mode TEXT NOT NULL DEFAULT 'auto' CHECK (assignment_mode IN ('auto','manual')),
priority DOUBLE PRECISION NOT NULL DEFAULT 0 CHECK (priority >= 0 AND priority <= 1),
is_reference_standard BOOLEAN NOT NULL DEFAULT FALSE,
skipped_seq BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One task per file (MVP simplification — droppable later for multi-task-per-file).
CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_tasks_file
ON imagehub_tasks (dataset_file_id);
-- Queue scan: a dataset's tasks at a given stage and status, highest priority first.
CREATE INDEX IF NOT EXISTS idx_imagehub_tasks_queue
ON imagehub_tasks (dataset_id, current_stage_id, queue_status, priority DESC);
-- A user's personal labeling queue across datasets.
CREATE INDEX IF NOT EXISTS idx_imagehub_tasks_assignee
ON imagehub_tasks (assignee_user_id, queue_status);
@@ -0,0 +1,8 @@
-- ImageHub: a task's labeler annotations (bbox / points / pen / brush / polygon) stored as JSON.
-- The shared viewer's annotation overlay emits normalized [0..1] vector geometry per slice — small
-- JSON, persisted on the task so the AnnotationTool can load + save a labeler's work. Idempotent
-- (ADD COLUMN IF NOT EXISTS) so the startup runner can apply it to volumes that predate it. Apply
-- after 021 (no semicolons inside comments or string literals — the runner splitter is naive):
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/022_imagehub_task_annotations.sql
ALTER TABLE imagehub_tasks ADD COLUMN IF NOT EXISTS annotations JSONB NOT NULL DEFAULT '[]'::jsonb;
@@ -0,0 +1,23 @@
-- ImageHub: dataset membership — lets users other than the owner work a dataset's tasks
-- (multi-labeler). MVP treats all members as labelers: they view the dataset and work tasks
-- assigned to them, while dataset / stage / settings management stays with the owner + platform
-- admins. The role column is reserved for a future project-admin tier. Idempotent. Apply after 022
-- (no semicolons inside comments or string literals — the runner splitter is naive):
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/023_imagehub_dataset_members.sql
CREATE TABLE IF NOT EXISTS imagehub_dataset_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('project_admin','member')),
added_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One membership per user per dataset.
CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_dataset_members_user
ON imagehub_dataset_members (dataset_id, user_id);
-- "Datasets I am a member of" lookup (the member's dataset list).
CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_members_user
ON imagehub_dataset_members (user_id);
@@ -0,0 +1,13 @@
-- ImageHub: link a dataset to a research project (the "workspace" superstructure). Nullable,
-- so existing datasets stay unlinked and a dataset can still exist standalone. A dataset created
-- from a project cockpit attaches to that project. ON DELETE SET NULL so deleting a project
-- orphans its datasets rather than dropping the imaging data. Idempotent. Apply after 023
-- (no semicolons inside comments or string literals — the runner splitter is naive):
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/024_imagehub_dataset_project_link.sql
ALTER TABLE imagehub_datasets
ADD COLUMN IF NOT EXISTS research_project_id UUID REFERENCES research_projects(id) ON DELETE SET NULL;
-- "Datasets in this project" lookup (the project-scoped dataset list).
CREATE INDEX IF NOT EXISTS idx_imagehub_datasets_research_project
ON imagehub_datasets (research_project_id);
@@ -0,0 +1,25 @@
-- ImageHub: structured review decisions. The task pipeline applies accept/acceptWithCorrections/
-- reject moves, but until now the verdict survived only as a free-text Vietnamese audit string —
-- not queryable, no reviewer/stage FK, no reject reason. This append-only table records every
-- review decision so review history + per-reviewer accept/reject counters become real. Idempotent.
-- Apply after 024 (no semicolons inside comments or string literals — the runner splitter is naive):
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/025_imagehub_task_review_events.sql
CREATE TABLE IF NOT EXISTS imagehub_task_review_events (
id BIGSERIAL PRIMARY KEY,
dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE,
task_id UUID NOT NULL REFERENCES imagehub_tasks(id) ON DELETE CASCADE,
stage_id UUID REFERENCES imagehub_dataset_stages(id) ON DELETE SET NULL,
reviewer_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
decision TEXT NOT NULL CHECK (decision IN ('accept','acceptWithCorrections','reject')),
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Per-reviewer counters over a date window (the productivity panel query).
CREATE INDEX IF NOT EXISTS idx_imagehub_review_events_reviewer
ON imagehub_task_review_events (dataset_id, reviewer_user_id, created_at);
-- A task's review history (chronological).
CREATE INDEX IF NOT EXISTS idx_imagehub_review_events_task
ON imagehub_task_review_events (task_id, created_at);
@@ -0,0 +1,21 @@
-- ImageHub: persist the relative folder path of each uploaded file (Option B — real folders inside
-- a dataset). Until now logical_path was basename-flattened, so an uploaded directory structure
-- (e.g. the nnU-Net imagesTr/labelsTr layout) was lost once files reached MinIO. folder_path keeps
-- the relative directory so the dataset browser can render a real folder tree and the structure
-- round-trips. The working-file natural key moves from (dataset_id, logical_path) to
-- (dataset_id, folder_path, logical_path) so two files sharing a basename in different folders no
-- longer collide and silently merge. Existing rows default folder_path to the empty string, so the
-- new key stays unique wherever the old one was. Idempotent.
-- Apply after 025 (no semicolons inside comments or string literals — the runner splitter is naive):
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/026_imagehub_file_folder_path.sql
ALTER TABLE imagehub_dataset_files
ADD COLUMN IF NOT EXISTS folder_path TEXT NOT NULL DEFAULT '';
DROP INDEX IF EXISTS uq_imagehub_dataset_files_path;
CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_dataset_files_folder_path
ON imagehub_dataset_files (dataset_id, folder_path, logical_path);
CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_files_folder
ON imagehub_dataset_files (dataset_id, folder_path);
@@ -0,0 +1,12 @@
-- ImageHub: per-dataset value to name label map for multi-label segmentation masks. A multi-label
-- labelsTr/<case>.nii.gz encodes each organ or structure as an integer voxel value (1, 2, 3 …). Until
-- now the viewer named those values from a fixed TotalSegmentator-v2 117-class map, so a non
-- TotalSegmentator dataset (KiTS = 1 kidney / 2 tumor / 3 cyst, or any custom nnU-Net labels) showed
-- confidently-wrong organ names. label_map stores the dataset own value to name mapping (a JSON object
-- with string keys), so the organ panel labels each overlay correctly and a user can edit them. The
-- empty default keeps the TotalSegmentator fallback for datasets without a map. Idempotent.
-- Apply after 026 (no semicolons inside comments or string literals — the runner splitter is naive):
-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/027_imagehub_dataset_label_map.sql
ALTER TABLE imagehub_datasets
ADD COLUMN IF NOT EXISTS label_map JSONB NOT NULL DEFAULT '{}'::jsonb;
+6
View File
@@ -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
+43
View File
@@ -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
+93
View File
@@ -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())
+86
View File
@@ -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."
+533
View File
@@ -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()))
+90
View File
@@ -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()
View File
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.
+235
View File
@@ -0,0 +1,235 @@
"""Admin-only audit log query API (GET /api/v1/admin/audit)."""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any, Optional
from fastapi import APIRouter, Header, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import asc, desc, func, select
from src.auth_jwt import decode_access_token_user_id, decode_bearer_token
from src.initiative_db.engine import get_session, init_engine, is_postgres_enabled
from src.initiative_db.models import AuditEvent
router = APIRouter(prefix="/admin", tags=["admin-audit"])
def _jwt_role_strings(authorization: str | None) -> list[str]:
p = decode_bearer_token(authorization)
if not p:
return []
r = p.get("roles")
if isinstance(r, list):
return [str(x) for x in r]
return []
def require_admin_uid(authorization: str | None) -> uuid.UUID:
uid = decode_access_token_user_id(authorization)
if uid is None:
raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.")
if "admin" not in _jwt_role_strings(authorization):
raise HTTPException(status_code=403, detail="Chỉ tài khoản quản trị mới thực hiện được.")
return uid
class AuditEventListItem(BaseModel):
model_config = {"from_attributes": True}
id: int
occurred_at: datetime
actor_user_id: Optional[uuid.UUID] = None
actor_email: str
actor_role: str
action: str
entity_type: str
entity_id: Optional[str] = None
metadata: dict[str, Any] = Field(default_factory=dict)
request_id: Optional[uuid.UUID] = None
has_before: bool = False
has_after: bool = False
class AuditListResponse(BaseModel):
items: list[AuditEventListItem]
total: int
page: int
page_size: int
class AuditEventDetail(BaseModel):
id: int
occurred_at: datetime
actor_user_id: Optional[uuid.UUID] = None
actor_email: str
actor_role: str
action: str
entity_type: str
entity_id: Optional[str] = None
before: Optional[dict[str, Any]] = None
after: Optional[dict[str, Any]] = None
metadata: dict[str, Any] = Field(default_factory=dict)
request_id: Optional[uuid.UUID] = None
_AUDIT_ACTIONS = frozenset(
{"create", "read", "update", "delete", "login", "logout", "login_failed"}
)
def _parse_sort(sort: str) -> bool:
"""True when sorting occurred_at ascending."""
s = (sort or "occurred_at:desc").strip().lower()
if ":" in s:
col_name, direction = s.split(":", 1)
else:
col_name, direction = s, "desc"
if col_name != "occurred_at":
raise HTTPException(status_code=400, detail='sort chỉ hỗ trợ occurred_at (+ asc|desc)')
return direction in ("asc", "ascending", "old", "older")
def _where_audit(
*,
from_ts: datetime,
to_ts: datetime,
actor_user_id: Optional[uuid.UUID],
actor_email: Optional[str],
entity_type: Optional[str],
entity_id: Optional[str],
actions: Optional[list[str]],
request_id: Optional[uuid.UUID],
):
parts = [
AuditEvent.occurred_at >= from_ts,
AuditEvent.occurred_at <= to_ts,
]
if actor_user_id is not None:
parts.append(AuditEvent.actor_user_id == actor_user_id)
if actor_email:
parts.append(AuditEvent.actor_email == actor_email.strip().lower())
if entity_type:
parts.append(AuditEvent.entity_type == entity_type.strip())
if entity_id is not None and entity_id.strip() != "":
parts.append(AuditEvent.entity_id == entity_id.strip())
if actions:
parts.append(AuditEvent.action.in_(actions))
if request_id is not None:
parts.append(AuditEvent.request_id == request_id)
return parts
@router.get("/audit", response_model=AuditListResponse)
async def list_audit_events(
authorization: Annotated[str | None, Header()] = None,
from_: Annotated[
Optional[datetime],
Query(alias="from", description="Inclusive lower bound (UTC). Default: now7d"),
] = None,
to: Annotated[
Optional[datetime],
Query(description="Inclusive upper bound (UTC). Default: now"),
] = None,
actor_user_id: Optional[uuid.UUID] = None,
actor_email: Optional[str] = None,
entity_type: Optional[str] = None,
entity_id: Optional[str] = None,
action: Optional[str] = Query(
None, description="Comma-separated audit_action values"
),
request_id: Optional[uuid.UUID] = None,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
sort: str = Query("occurred_at:desc", description='e.g. "occurred_at:desc"'),
):
require_admin_uid(authorization)
if not is_postgres_enabled():
raise HTTPException(status_code=503, detail="Cần PostgreSQL để đọc audit.")
await init_engine()
now = datetime.now(timezone.utc)
end = to or now
start = from_ or (end - timedelta(days=7))
if end < start:
raise HTTPException(status_code=400, detail="Tham số to phải >= from")
actions_list: Optional[list[str]] = None
if action:
raw = [a.strip().lower() for a in action.split(",") if a.strip()]
bad = [a for a in raw if a not in _AUDIT_ACTIONS]
if bad:
raise HTTPException(status_code=400, detail=f"action không hợp lệ: {bad}")
actions_list = raw
asc_order = _parse_sort(sort)
offset = (page - 1) * page_size
wh = _where_audit(
from_ts=start,
to_ts=end,
actor_user_id=actor_user_id,
actor_email=actor_email,
entity_type=entity_type,
entity_id=entity_id,
actions=actions_list,
request_id=request_id,
)
async with get_session() as session:
cnt_stmt = select(func.count()).select_from(AuditEvent).where(*wh)
total = int((await session.execute(cnt_stmt)).scalar_one())
order_clause = asc(AuditEvent.occurred_at) if asc_order else desc(AuditEvent.occurred_at)
stmt = select(AuditEvent).where(*wh).order_by(order_clause).limit(page_size).offset(offset)
rows = (await session.execute(stmt)).scalars().all()
items = [
AuditEventListItem(
id=r.id,
occurred_at=r.occurred_at,
actor_user_id=r.actor_user_id,
actor_email=r.actor_email,
actor_role=r.actor_role,
action=str(r.action),
entity_type=r.entity_type,
entity_id=r.entity_id,
metadata=dict(r.metadata_) if isinstance(r.metadata_, dict) else {},
request_id=r.request_id,
has_before=r.before is not None,
has_after=r.after is not None,
)
for r in rows
]
return AuditListResponse(items=items, total=total, page=page, page_size=page_size)
@router.get("/audit/{event_id:int}", response_model=AuditEventDetail)
async def get_audit_event_detail(
event_id: int,
authorization: Annotated[str | None, Header()] = None,
):
require_admin_uid(authorization)
if not is_postgres_enabled():
raise HTTPException(status_code=503, detail="Cần PostgreSQL để đọc audit.")
await init_engine()
async with get_session() as session:
row = await session.get(AuditEvent, event_id)
if row is None:
raise HTTPException(status_code=404, detail="Không có sự kiện audit.")
return AuditEventDetail(
id=row.id,
occurred_at=row.occurred_at,
actor_user_id=row.actor_user_id,
actor_email=row.actor_email,
actor_role=row.actor_role,
action=str(row.action),
entity_type=row.entity_type,
entity_id=row.entity_id,
before=dict(row.before) if isinstance(row.before, dict) else row.before,
after=dict(row.after) if isinstance(row.after, dict) else row.after,
metadata=dict(row.metadata_) if isinstance(row.metadata_, dict) else {},
request_id=row.request_id,
)
+609
View File
@@ -0,0 +1,609 @@
"""Admin APIs for staff profile verification queue (conditional updates + audit)."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.exc import IntegrityError, ProgrammingError
from sqlalchemy.ext.asyncio import AsyncSession
from src.audit import AuditAction, record_audit, resolve_actor_fields
from src.auth_api import _policy_admin_emails
from src.auth_jwt import decode_access_token_user_id, decode_bearer_token
from src.initiative_db.engine import get_session, is_postgres_enabled
from src.initiative_db.models import (
AcademicTitle,
ApplicationAdminResult,
ApplicationArtifact,
ApplicationReviewDocument,
AuditLog,
Initiative,
Unit,
User,
UserRoleRow,
UserStaffProfile,
)
from src.staff_profile_domain import staff_row_for_audit
router = APIRouter(prefix="/admin/user-profiles", tags=["admin-user-profiles"])
SYSTEM_DRAFT_USER_ID = uuid.UUID("00000000-0000-4000-8000-000000000001")
_FRONTEND_ROLES = frozenset({"admin", "editor", "viewer"})
def _jwt_role_strings(authorization: str | None) -> list[str]:
p = decode_bearer_token(authorization)
if not p:
return []
r = p.get("roles")
if isinstance(r, list):
return [str(x) for x in r]
return []
def _require_admin_uid(authorization: str | None) -> uuid.UUID:
uid = decode_access_token_user_id(authorization)
if uid is None:
raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.")
if "admin" not in _jwt_role_strings(authorization):
raise HTTPException(status_code=403, detail="Chỉ tài khoản quản trị mới thực hiện được.")
return uid
class PendingProfileItem(BaseModel):
userId: str
email: str
fullName: str
employeeId: Optional[str] = None
jobTitle: Optional[str] = None
verificationSubmittedAt: Optional[datetime] = None
version: int
class RegisteredUserItem(BaseModel):
"""Active accounts with staff profile snapshot (admin read-only directory)."""
userId: str
email: str
fullName: str
phone: Optional[str] = None
createdAt: datetime
employeeId: Optional[str] = None
jobTitle: Optional[str] = None
unitNameFreetext: Optional[str] = None
unitCatalogName: Optional[str] = None
academicTitleLabelVi: Optional[str] = None
academicTitleOther: Optional[str] = None
profileVerificationStatus: str = "draft"
roles: list[str] = Field(default_factory=list)
adminFromPolicy: bool = False
policyAdminLocked: bool = False
class ProfileDetailResponse(BaseModel):
userId: str
email: str
fullName: str
phone: Optional[str] = None
unitId: Optional[str] = None
unitCatalogName: Optional[str] = None
staffProfile: dict[str, Any]
class VerifyBody(BaseModel):
expectedVersion: int = Field(..., ge=1)
class RejectBody(BaseModel):
expectedVersion: int = Field(..., ge=1)
reason: str = Field(..., min_length=1, max_length=2000)
class RemoveUserBody(BaseModel):
confirmEmail: str = Field(..., min_length=3, max_length=320)
class SetUserRolesBody(BaseModel):
admin: bool = False
editor: bool = False
viewer: bool = False
class UserRolesStateResponse(BaseModel):
roles: list[str]
adminFromPolicy: bool
policyAdminLocked: bool
@router.get("/pending", response_model=list[PendingProfileItem])
async def list_pending(authorization: str | None = Header(None)) -> list[PendingProfileItem]:
_require_admin_uid(authorization)
if not is_postgres_enabled():
raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.")
async with get_session() as session:
stmt = (
select(User, UserStaffProfile)
.join(UserStaffProfile, UserStaffProfile.user_id == User.id)
.where(UserStaffProfile.profile_verification_status == "pending", User.is_active.is_(True))
.order_by(UserStaffProfile.verification_submitted_at.asc().nulls_last())
)
rows = (await session.execute(stmt)).all()
out: list[PendingProfileItem] = []
for user, sp in rows:
out.append(
PendingProfileItem(
userId=str(user.id),
email=user.email,
fullName=user.full_name,
employeeId=sp.employee_id,
jobTitle=sp.job_title,
verificationSubmittedAt=sp.verification_submitted_at,
version=sp.version,
)
)
return out
@router.get("/registry", response_model=list[RegisteredUserItem])
async def list_registered_users(authorization: str | None = Header(None)) -> list[RegisteredUserItem]:
"""All active user accounts (successful registration) with HR fields for review / export."""
_require_admin_uid(authorization)
if not is_postgres_enabled():
raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.")
async with get_session() as session:
stmt = (
select(User, UserStaffProfile, Unit, AcademicTitle)
.outerjoin(UserStaffProfile, UserStaffProfile.user_id == User.id)
.outerjoin(Unit, Unit.id == User.unit_id)
.outerjoin(AcademicTitle, AcademicTitle.code == UserStaffProfile.academic_title_code)
.where(User.is_active.is_(True))
.order_by(User.created_at.desc())
)
rows = (await session.execute(stmt)).all()
by_user_roles: dict[uuid.UUID, list[UserRoleRow]] = {}
if rows:
uids = [r[0].id for r in rows]
role_stmt = select(UserRoleRow).where(UserRoleRow.user_id.in_(uids))
for rr in (await session.execute(role_stmt)).scalars().all():
by_user_roles.setdefault(rr.user_id, []).append(rr)
policy = _policy_admin_emails()
out: list[RegisteredUserItem] = []
for user, sp, unit, title in rows:
status = "draft"
if sp is not None:
status = sp.profile_verification_status or "draft"
ur = by_user_roles.get(user.id, [])
role_set = sorted({str(x.role) for x in ur if str(x.role) in _FRONTEND_ROLES})
admin_fp = any(
str(x.role) == "admin" and bool(x.admin_from_email_policy) for x in ur
)
email_norm = user.email.strip().lower()
policy_lock = email_norm in policy
out.append(
RegisteredUserItem(
userId=str(user.id),
email=user.email,
fullName=user.full_name,
phone=user.phone,
createdAt=user.created_at,
employeeId=sp.employee_id if sp else None,
jobTitle=sp.job_title if sp else None,
unitNameFreetext=sp.unit_name_freetext if sp else None,
unitCatalogName=unit.name if unit is not None else None,
academicTitleLabelVi=title.label_vi if title is not None else None,
academicTitleOther=sp.academic_title_other if sp else None,
profileVerificationStatus=status,
roles=role_set,
adminFromPolicy=admin_fp,
policyAdminLocked=policy_lock,
)
)
return out
async def _detail(session: AsyncSession, user_id: uuid.UUID) -> ProfileDetailResponse | None:
stmt = (
select(User, UserStaffProfile)
.join(UserStaffProfile, UserStaffProfile.user_id == User.id)
.where(User.id == user_id)
)
row = (await session.execute(stmt)).first()
if row is None:
return None
user, sp = row
unit_name: str | None = None
if user.unit_id is not None:
u = await session.get(Unit, user.unit_id)
if u is not None:
unit_name = u.name
staff = {
"employeeId": sp.employee_id,
"academicTitleCode": sp.academic_title_code,
"academicTitleOther": sp.academic_title_other,
"unitNameFreetext": sp.unit_name_freetext,
"jobTitle": sp.job_title,
"profileVerificationStatus": sp.profile_verification_status,
"verificationSubmittedAt": sp.verification_submitted_at,
"verifiedAt": sp.verified_at,
"verifiedByUserId": str(sp.verified_by_user_id) if sp.verified_by_user_id else None,
"rejectionReason": sp.rejection_reason,
"version": sp.version,
}
return ProfileDetailResponse(
userId=str(user.id),
email=user.email,
fullName=user.full_name,
phone=user.phone,
unitId=str(user.unit_id) if user.unit_id else None,
unitCatalogName=unit_name,
staffProfile=staff,
)
@router.get("/{user_id}", response_model=ProfileDetailResponse)
async def get_profile_detail(
user_id: uuid.UUID, authorization: str | None = Header(None)
) -> ProfileDetailResponse:
_require_admin_uid(authorization)
if not is_postgres_enabled():
raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.")
async with get_session() as session:
detail = await _detail(session, user_id)
if detail is None:
raise HTTPException(status_code=404, detail="Không tìm thấy người dùng.")
return detail
@router.patch("/{user_id}/roles", response_model=UserRolesStateResponse)
async def set_user_roles(
user_id: uuid.UUID,
body: SetUserRolesBody,
authorization: str | None = Header(None),
) -> UserRolesStateResponse:
"""Replace app-facing roles (admin / editor / viewer) for a user."""
admin_id = _require_admin_uid(authorization)
if user_id == SYSTEM_DRAFT_USER_ID:
raise HTTPException(status_code=400, detail="Không thể sửa vai trò tài khoản hệ thống.")
if not is_postgres_enabled():
raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.")
desired: set[str] = set()
if body.admin:
desired.add("admin")
if body.editor:
desired.add("editor")
if body.viewer:
desired.add("viewer")
if not desired:
raise HTTPException(status_code=400, detail="Chọn ít nhất một vai trò.")
policy = _policy_admin_emails()
async with get_session() as session:
user = await session.get(User, user_id)
if user is None or not user.is_active:
raise HTTPException(status_code=404, detail="Không tìm thấy người dùng.")
email_norm = user.email.strip().lower()
if email_norm in policy and "admin" not in desired:
raise HTTPException(
status_code=400,
detail="Không thể gỡ quyền Quản trị: email thuộc danh sách quản trị hệ thống.",
)
if user_id == admin_id and "admin" not in desired:
raise HTTPException(
status_code=400,
detail="Không thể tự gỡ quyền Quản trị của chính mình.",
)
stmt = select(UserRoleRow).where(UserRoleRow.user_id == user_id)
existing = list((await session.execute(stmt)).scalars().all())
current_front = {str(r.role) for r in existing if str(r.role) in _FRONTEND_ROLES}
before_roles = sorted(current_front)
to_remove = current_front - desired
to_add = desired - current_front
for role in to_remove:
await session.execute(
delete(UserRoleRow).where(
UserRoleRow.user_id == user_id,
UserRoleRow.role == role,
)
)
for role in to_add:
session.add(
UserRoleRow(
user_id=user_id,
role=role,
admin_from_email_policy=bool(role == "admin" and email_norm in policy),
)
)
if to_remove or to_add:
user.credential_version = int(user.credential_version or 0) + 1
user.updated_at = datetime.now(timezone.utc)
after_roles = sorted(desired)
if before_roles != after_roles:
actor_email, actor_role = await resolve_actor_fields(session, admin_id)
await record_audit(
session,
actor_user_id=admin_id,
actor_email=actor_email,
actor_role=actor_role,
action=AuditAction.update,
entity_type="user_roles",
entity_id=str(user_id),
before={"roles": before_roles},
after={"roles": after_roles},
metadata={"action": "set_roles"},
)
await session.flush()
stmt2 = select(UserRoleRow).where(UserRoleRow.user_id == user_id)
final_rows = list((await session.execute(stmt2)).scalars().all())
role_list = sorted({str(r.role) for r in final_rows if str(r.role) in _FRONTEND_ROLES})
admin_fp = any(
str(r.role) == "admin" and bool(r.admin_from_email_policy) for r in final_rows
)
return UserRolesStateResponse(
roles=role_list,
adminFromPolicy=admin_fp,
policyAdminLocked=(email_norm in policy),
)
@router.post("/{user_id}/verify")
async def verify_profile(
user_id: uuid.UUID,
body: VerifyBody,
authorization: str | None = Header(None),
) -> dict[str, str]:
admin_id = _require_admin_uid(authorization)
if not is_postgres_enabled():
raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.")
now = datetime.now(timezone.utc)
async with get_session() as session:
sp = await session.get(UserStaffProfile, user_id)
user = await session.get(User, user_id)
if sp is None or user is None:
raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ.")
before = staff_row_for_audit(sp, user.unit_id)
stmt = (
update(UserStaffProfile)
.where(
UserStaffProfile.user_id == user_id,
UserStaffProfile.profile_verification_status == "pending",
UserStaffProfile.version == body.expectedVersion,
)
.values(
profile_verification_status="verified",
verified_at=now,
verified_by_user_id=admin_id,
rejection_reason=None,
version=UserStaffProfile.version + 1,
updated_at=now,
)
)
res = await session.execute(stmt)
if res.rowcount == 0:
raise HTTPException(
status_code=409,
detail="Không thể xác minh: trạng thái đã đổi hoặc phiên bản không khớp (vui lòng tải lại).",
)
await session.refresh(sp)
after = staff_row_for_audit(sp, user.unit_id)
actor_email, actor_role = await resolve_actor_fields(session, admin_id)
await record_audit(
session,
actor_user_id=admin_id,
actor_email=actor_email,
actor_role=actor_role,
action=AuditAction.update,
entity_type="user_profile",
entity_id=str(user_id),
before=before,
after=after,
metadata={"action": "verify"},
)
return {"status": "verified"}
@router.post("/{user_id}/reject")
async def reject_profile(
user_id: uuid.UUID,
body: RejectBody,
authorization: str | None = Header(None),
) -> dict[str, str]:
admin_id = _require_admin_uid(authorization)
if not is_postgres_enabled():
raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.")
reason = body.reason.strip()
if not reason:
raise HTTPException(status_code=400, detail="Cần lý do từ chối.")
now = datetime.now(timezone.utc)
async with get_session() as session:
sp = await session.get(UserStaffProfile, user_id)
user = await session.get(User, user_id)
if sp is None or user is None:
raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ.")
before = staff_row_for_audit(sp, user.unit_id)
stmt = (
update(UserStaffProfile)
.where(
UserStaffProfile.user_id == user_id,
UserStaffProfile.profile_verification_status == "pending",
UserStaffProfile.version == body.expectedVersion,
)
.values(
profile_verification_status="rejected",
verified_at=None,
verified_by_user_id=None,
rejection_reason=reason,
version=UserStaffProfile.version + 1,
updated_at=now,
)
)
res = await session.execute(stmt)
if res.rowcount == 0:
raise HTTPException(
status_code=409,
detail="Không thể từ chối: trạng thái đã đổi hoặc phiên bản không khớp (vui lòng tải lại).",
)
await session.refresh(sp)
after = staff_row_for_audit(sp, user.unit_id)
actor_email, actor_role = await resolve_actor_fields(session, admin_id)
await record_audit(
session,
actor_user_id=admin_id,
actor_email=actor_email,
actor_role=actor_role,
action=AuditAction.update,
entity_type="user_profile",
entity_id=str(user_id),
before=before,
after=after,
metadata={"action": "reject"},
)
return {"status": "rejected"}
@router.post("/{user_id}/remove")
async def remove_user_account(
user_id: uuid.UUID,
body: RemoveUserBody,
authorization: str | None = Header(None),
) -> dict[str, str]:
"""
Permanently delete a user row (cascades roles, staff profile, OTP tokens, etc.).
Blocked for admins, the system draft user, self-delete, and accounts that still own initiatives.
"""
admin_id = _require_admin_uid(authorization)
if user_id == admin_id:
raise HTTPException(status_code=400, detail="Không thể xóa chính mình.")
if user_id == SYSTEM_DRAFT_USER_ID:
raise HTTPException(status_code=400, detail="Không thể xóa tài khoản hệ thống.")
if not is_postgres_enabled():
raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.")
confirm = body.confirmEmail.strip().lower()
if not confirm:
raise HTTPException(status_code=400, detail="Cần nhập email để xác nhận.")
async with get_session() as session:
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="Không tìm thấy người dùng.")
if user.email.strip().lower() != confirm:
raise HTTPException(status_code=400, detail="Email xác nhận không khớp tài khoản.")
admin_stmt = select(UserRoleRow.user_id).where(
UserRoleRow.user_id == user_id,
UserRoleRow.role == "admin",
)
if (await session.execute(admin_stmt)).first() is not None:
raise HTTPException(status_code=403, detail="Không thể xóa tài khoản quản trị.")
own_count = (
await session.execute(
select(func.count()).select_from(Initiative).where(Initiative.owner_id == user_id)
)
).scalar_one()
if own_count and int(own_count) > 0:
raise HTTPException(
status_code=409,
detail="Tài khoản còn sáng kiến/đơn (owner). Xóa hoặc chuyển dữ liệu trước.",
)
await session.execute(
update(UserStaffProfile)
.where(UserStaffProfile.verified_by_user_id == user_id)
.values(verified_by_user_id=None)
)
await session.execute(
update(ApplicationArtifact)
.where(ApplicationArtifact.uploaded_by == user_id)
.values(uploaded_by=None)
)
await session.execute(
update(ApplicationReviewDocument)
.where(ApplicationReviewDocument.created_by == user_id)
.values(created_by=None)
)
await session.execute(
update(ApplicationAdminResult)
.where(ApplicationAdminResult.created_by == user_id)
.values(created_by=None)
)
await session.execute(
update(ApplicationAdminResult)
.where(ApplicationAdminResult.updated_by == user_id)
.values(updated_by=None)
)
await session.execute(update(AuditLog).where(AuditLog.actor_id == user_id).values(actor_id=None))
async with session.begin_nested():
try:
await session.execute(
text("UPDATE authors SET user_id = NULL WHERE user_id = CAST(:uid AS uuid)"),
{"uid": str(user_id)},
)
except ProgrammingError:
pass
before_user = {
"userId": str(user.id),
"email": user.email,
"fullName": user.full_name,
}
actor_email, actor_role = await resolve_actor_fields(session, admin_id)
await record_audit(
session,
actor_user_id=admin_id,
actor_email=actor_email,
actor_role=actor_role,
action=AuditAction.delete,
entity_type="user",
entity_id=str(user_id),
before=before_user,
after=None,
metadata={"action": "admin_remove_user"},
)
# Delete staff profile before User: ORM would otherwise try to NULL user_staff_profiles.user_id,
# which is the row's primary key (AssertionError on flush).
sp_row = await session.get(UserStaffProfile, user_id)
if sp_row is not None:
await session.delete(sp_row)
await session.flush()
try:
await session.delete(user)
await session.flush()
except IntegrityError:
await session.rollback()
raise HTTPException(
status_code=409,
detail="Không thể xóa: tài khoản còn được tham chiếu (ví dụ: minh chứng, đánh giá hội đồng).",
) from None
return {"status": "deleted"}
+6
View File
@@ -0,0 +1,6 @@
"""Application layer — use cases that orchestrate domain objects via ports.
Depends on ``domain`` + ``shared_kernel`` only. Knows nothing about FastAPI,
SQLAlchemy, JWT, or argon2 — those arrive as ``ports`` (Protocols) injected by the
composition root. A use case is one business operation, testable with fakes.
"""
+1
View File
@@ -0,0 +1 @@
"""Identity use cases (Login is the first cut-over reference)."""
+24
View File
@@ -0,0 +1,24 @@
"""Application DTOs for Identity — the inputs/outputs of use cases (not API schemas)."""
from __future__ import annotations
from dataclasses import dataclass
from src.domain.identity.entities import User
@dataclass(frozen=True)
class LoginCommand:
email: str
password: str
client_ip: str
@dataclass(frozen=True)
class AuthenticatedUser:
"""Result of a successful authentication. The API layer assembles the public
response (incl. staff profile) from this + a profile read."""
user: User
roles: list[str]
access_token: str
+54
View File
@@ -0,0 +1,54 @@
"""Driven ports for the Identity application layer.
Each is a structural ``Protocol`` implemented by an adapter in ``infrastructure``.
The use cases program against these, never against the concrete library.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Protocol
class PasswordHasher(Protocol):
"""Argon2id in production (``infrastructure.identity.argon2_hasher``)."""
def hash(self, plain: str) -> str: ...
def verify(self, plain: str, hashed: str) -> bool: ...
class TokenIssuer(Protocol):
"""Signs the access token from claims built by the domain."""
def issue(
self,
user_id: uuid.UUID,
email: str,
roles: list[str],
credential_version: int,
) -> str: ...
class LoginRateLimiter(Protocol):
"""Per-(email, ip) sliding window; returns False when the request must be denied."""
def allow(self, email: str, client_ip: str) -> bool: ...
class Clock(Protocol):
"""Injectable time source (keeps use cases deterministic in tests)."""
def now(self) -> datetime: ...
class AuthAuditSink(Protocol):
"""Append-only audit of authentication outcomes."""
async def login_succeeded(
self, *, user_id: uuid.UUID, email: str, roles: list[str]
) -> None: ...
async def login_failed(
self, *, email: str, user_id: uuid.UUID | None, reason: str | None
) -> None: ...
@@ -0,0 +1 @@
"""One module per use case — one business operation each."""
@@ -0,0 +1,80 @@
"""AuthenticateUser — the ``POST /auth/login`` orchestration, framework-free.
Behavior mirrors ``auth_api.login`` exactly so the cut-over is invisible to clients:
institutional-email normalization → rate limit (429) → credential check (401) →
email-verified check (403) → role reconcile → audit → signed token.
Depends only on ports + domain; unit-tested with fakes (no DB). Status mapping
(DomainError subclass → HTTP code) happens in the API layer.
"""
from __future__ import annotations
from src.application.identity.dto import AuthenticatedUser, LoginCommand
from src.application.identity.ports import (
AuthAuditSink,
LoginRateLimiter,
PasswordHasher,
TokenIssuer,
)
from src.domain.identity.errors import EmailNotVerified, InvalidCredentials
from src.domain.identity.repository import UserRepository
from src.domain.identity.value_objects import InstitutionalEmail
from src.shared_kernel.errors import RateLimited
# Vietnamese messages preserved verbatim from auth_api.login.
_INVALID_CREDENTIALS_MSG = "Email hoặc mật khẩu không đúng."
_EMAIL_UNVERIFIED_MSG = (
"Vui lòng xác minh email trước khi đăng nhập. Kiểm tra hộp thư "
"hoặc dùng chức năng gửi lại mã OTP trên trang đăng ký."
)
_RATE_LIMITED_MSG = "Quá nhiều lần đăng nhập. Vui lòng thử lại sau."
class AuthenticateUser:
def __init__(
self,
*,
users: UserRepository,
hasher: PasswordHasher,
tokens: TokenIssuer,
rate_limiter: LoginRateLimiter,
audit: AuthAuditSink,
) -> None:
self._users = users
self._hasher = hasher
self._tokens = tokens
self._rate_limiter = rate_limiter
self._audit = audit
async def execute(self, command: LoginCommand) -> AuthenticatedUser:
email = InstitutionalEmail.parse(command.email) # raises 400 on bad domain
if not self._rate_limiter.allow(email.value, command.client_ip):
raise RateLimited(_RATE_LIMITED_MSG)
user = await self._users.get_by_email(email.value)
# Wrong creds and unknown/inactive email are indistinguishable → 401.
if (
user is None
or not user.can_authenticate()
or not self._hasher.verify(command.password, user.password_hash)
):
await self._audit.login_failed(
email=email.value,
user_id=user.id if user is not None else None,
reason=None,
)
raise InvalidCredentials(_INVALID_CREDENTIALS_MSG)
# Correct creds but unverified email → 403 (distinct from 401).
if user.requires_email_verification():
await self._audit.login_failed(
email=email.value, user_id=user.id, reason="email_unverified"
)
raise EmailNotVerified(_EMAIL_UNVERIFIED_MSG)
roles = await self._users.roles_after_reconcile(user)
await self._audit.login_succeeded(user_id=user.id, email=user.email, roles=roles)
token = self._tokens.issue(user.id, user.email, roles, user.credential_version)
return AuthenticatedUser(user=user, roles=roles, access_token=token)

Some files were not shown because too many files have changed in this diff Show More