sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper: apply migration 007 on an existing Postgres volume. See be0 script for options.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
exec "$ROOT/be0/scripts/apply-migration-007.sh" "$@"
|
||||
@@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
REM Wrapper cho build-all.ps1 — ưu tiên pwsh (PS7+), fallback powershell (PS5.1)
|
||||
setlocal
|
||||
set ROOT=%~dp0..
|
||||
pushd "%ROOT%"
|
||||
|
||||
where pwsh >nul 2>&1
|
||||
if %ERRORLEVEL% == 0 (
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-all.ps1" %*
|
||||
) else (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-all.ps1" %*
|
||||
)
|
||||
set RC=%ERRORLEVEL%
|
||||
|
||||
popd
|
||||
endlocal & exit /b %RC%
|
||||
@@ -0,0 +1,129 @@
|
||||
# Build all components: .NET backend + 2 React frontends
|
||||
# Usage:
|
||||
# .\scripts\build-all.ps1 # Build all
|
||||
# .\scripts\build-all.ps1 -Clean # Clean + Build
|
||||
# .\scripts\build-all.ps1 -Backend # Backend only
|
||||
# .\scripts\build-all.ps1 -Frontend # Frontends only
|
||||
# .\scripts\build-all.ps1 -Restore # Just restore deps
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Clean,
|
||||
[switch]$Backend,
|
||||
[switch]$Frontend,
|
||||
[switch]$Restore,
|
||||
[switch]$NoNpmInstall,
|
||||
[string]$Configuration = 'Release'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $root
|
||||
|
||||
$All = -not ($Backend -or $Frontend -or $Restore)
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " $Message" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Test-CommandExists {
|
||||
param([string]$Command)
|
||||
return ($null -ne (Get-Command $Command -ErrorAction SilentlyContinue))
|
||||
}
|
||||
|
||||
function Invoke-Step {
|
||||
param([string]$Description, [scriptblock]$Block)
|
||||
Write-Host "-> $Description" -ForegroundColor Yellow
|
||||
& $Block
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "[FAIL] $Description" -ForegroundColor Red
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
}
|
||||
|
||||
# Pre-flight checks
|
||||
Write-Step "Pre-flight checks"
|
||||
if (-not (Test-CommandExists 'dotnet')) {
|
||||
Write-Host "[ERROR] dotnet not found" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$dotnetVersion = (dotnet --version)
|
||||
Write-Host "[OK] .NET SDK: $dotnetVersion" -ForegroundColor Green
|
||||
|
||||
if (Test-CommandExists 'node') {
|
||||
Write-Host "[OK] Node.js: $(node --version)" -ForegroundColor Green
|
||||
}
|
||||
if (Test-CommandExists 'npm') {
|
||||
Write-Host "[OK] npm: $(npm --version)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Backend
|
||||
if ($All -or $Backend -or $Restore -or $Clean) {
|
||||
if ($Clean) {
|
||||
Write-Step "Clean .NET solution"
|
||||
Invoke-Step "dotnet clean" { dotnet clean DH_Y_DUOC.slnx -c $Configuration -v minimal }
|
||||
}
|
||||
|
||||
Write-Step ".NET: Restore packages"
|
||||
Invoke-Step "dotnet restore" { dotnet restore DH_Y_DUOC.slnx }
|
||||
|
||||
if (-not $Restore) {
|
||||
Write-Step ".NET: Build solution ($Configuration)"
|
||||
Invoke-Step "dotnet build" { dotnet build DH_Y_DUOC.slnx -c $Configuration --no-restore -v minimal }
|
||||
}
|
||||
}
|
||||
|
||||
# Frontends
|
||||
if ($All -or $Frontend) {
|
||||
if (-not (Test-CommandExists 'npm')) {
|
||||
Write-Host "[WARN] Skip frontends (npm not installed)" -ForegroundColor Yellow
|
||||
}
|
||||
else {
|
||||
$frontends = @('fe0', 'fe-admin')
|
||||
foreach ($fe in $frontends) {
|
||||
$feDir = Join-Path $root $fe
|
||||
if (-not (Test-Path $feDir)) {
|
||||
Write-Host "[SKIP] $fe directory not found" -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
Write-Step "Frontend $fe : Build"
|
||||
Push-Location $feDir
|
||||
try {
|
||||
$needInstall = (-not $NoNpmInstall) -and ((-not (Test-Path 'node_modules')) -or $Clean)
|
||||
if ($needInstall) {
|
||||
if ($Clean -and (Test-Path 'node_modules')) {
|
||||
Write-Host "-> Clean: remove node_modules" -ForegroundColor Yellow
|
||||
Remove-Item -Recurse -Force node_modules
|
||||
}
|
||||
Invoke-Step "npm install" { npm install }
|
||||
}
|
||||
else {
|
||||
Write-Host "[OK] node_modules exists (skip npm install)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
if (-not $Restore) {
|
||||
Invoke-Step "npm run build" { npm run build }
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Summary
|
||||
Write-Step "BUILD COMPLETED"
|
||||
Write-Host " - .NET output: src/Backend/DYD.Api/bin/$Configuration/net10.0/"
|
||||
Write-Host " - fe0 output: fe0/dist/"
|
||||
Write-Host " - fe-admin output: fe-admin/dist/"
|
||||
Write-Host ""
|
||||
Write-Host "To run dev:" -ForegroundColor Cyan
|
||||
Write-Host " Backend: dotnet run --project src/Backend/DYD.Api (or F5 in VS)"
|
||||
Write-Host " Frontends: .\scripts\start-frontends.cmd"
|
||||
Write-Host " AI: docker compose up -d qdrant aiservice"
|
||||
Write-Host ""
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build Word template from original form file by replacing dots-lines ("..........")
|
||||
với placeholders {{xxx}} theo vị trí label trong document.
|
||||
|
||||
Usage:
|
||||
python scripts/build-word-template.py
|
||||
|
||||
Input: src/Backend/DYD.Api/Templates/bao-cao-template-original.docx
|
||||
Output: src/Backend/DYD.Api/Templates/bao-cao-template.docx
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
NS_URI = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
W = f'{{{NS_URI}}}'
|
||||
ET.register_namespace('w', NS_URI)
|
||||
|
||||
# Paragraph index → placeholder token (after dot-line replacement).
|
||||
# Determined by running script once and printing all paragraphs, then mapping manually.
|
||||
# Key = paragraph index (in document iteration order)
|
||||
# Value = placeholder name ({{VALUE}} will be inserted if current paragraph is dots-line
|
||||
# OR appended to preceding label if current paragraph IS the label itself)
|
||||
|
||||
# For cover page (no dots after labels), we append placeholder AT END of label paragraph
|
||||
# Format: label_text_regex → placeholder (inject at end of paragraph)
|
||||
COVER_LABEL_APPEND = [
|
||||
(re.compile(r'^Tên sáng kiến \(Tiếng Việt\):\s*$'), 'coverInitiativeName'),
|
||||
(re.compile(r'^Tác giả/nhóm tác giả sáng kiến:\s*$'), 'coverAuthors'),
|
||||
(re.compile(r'^Đơn vị công tác:\s*$'), 'coverUnit'),
|
||||
(re.compile(r'^Thông tin liên hệ \(Điện thoại, Email\):\s*$'), 'coverContact'),
|
||||
(re.compile(r'^NĂM 20\.\.\.$'), 'coverYear'),
|
||||
]
|
||||
|
||||
# Map: label regex (previous paragraph) → placeholder for the dots-paragraph following
|
||||
LABEL_TO_PLACEHOLDER = [
|
||||
# Mẫu 01
|
||||
(re.compile(r'^1\.\s*Mở đầu'), 'introduction'),
|
||||
(re.compile(r'^2\.\s*Tên sáng kiến\b'), 'initiativeName'),
|
||||
(re.compile(r'^3\.\s*Lĩnh vực áp dụng'), 'applicationField'),
|
||||
(re.compile(r'^4\.1\s*Tình trạng giải pháp'), 'currentStatus'),
|
||||
(re.compile(r'^-\s*Mục đích của sáng kiến'), 'purpose'),
|
||||
(re.compile(r'^\+\s*Các bước thực hiện'), 'implementationSteps'),
|
||||
(re.compile(r'^\+\s*Các điều kiện cần thiết'), 'conditions'),
|
||||
(re.compile(r'^-\s*Về tính mới'), 'novelty'),
|
||||
# Effectiveness 10 items
|
||||
(re.compile(r'^\+\s*Tạo ra lợi ích kinh tế'), 'effEconomic'),
|
||||
(re.compile(r'^\+\s*Đem lại hiệu quả trong giảng dạy'), 'effTeaching'),
|
||||
(re.compile(r'^\+\s*Tăng năng suất lao động'), 'effProductivity'),
|
||||
(re.compile(r'^\+\s*Nâng cao hiệu quả công việc'), 'effSocial'),
|
||||
(re.compile(r'^\+\s*Nâng cao chất lượng công việc'), 'effQuality'),
|
||||
(re.compile(r'^\+\s*Giảm chi phí'), 'effCost'),
|
||||
(re.compile(r'^\+\s*Cải thiện môi trường'), 'effEnvironment'),
|
||||
(re.compile(r'^\+\s*Bảo vệ sức khỏe'), 'effHealth'),
|
||||
(re.compile(r'^\+\s*Đảm bảo an toàn lao động'), 'effLaborSafety'),
|
||||
(re.compile(r'^\+\s*Nâng cao khả năng, trình độ'), 'effAwareness'),
|
||||
(re.compile(r'^6\.\s*Những thông tin cần được bảo mật'), 'confidentialInfo'),
|
||||
# Mẫu 02
|
||||
(re.compile(r'^-\s*Chủ đầu tư tạo ra sáng kiến'), 'investorName'),
|
||||
(re.compile(r'^-\s*Lĩnh vực áp dụng sáng kiến'), 'applicationField02'),
|
||||
(re.compile(r'^-\s*Ngày sáng kiến được áp dụng'), 'firstApplyDate'),
|
||||
(re.compile(r'^-\s*Nội dung của sáng kiến'), 'contentSummary'),
|
||||
(re.compile(r'^Những thông tin cần được bảo mật'), 'confidentialInfo02'),
|
||||
(re.compile(r'^Các điều kiện cần thiết để áp dụng'), 'conditions02'),
|
||||
(re.compile(r'^Đánh giá lợi ích thu được hoặc dự kiến có thể thu được do áp dụng sáng kiến theo ý kiến của tác giả'), 'authorEvaluation'),
|
||||
(re.compile(r'^Đánh giá lợi ích thu được hoặc dự kiến có thể thu được do áp dụng sáng kiến theo ý kiến của tổ chức'), 'trialEvaluation'),
|
||||
# Mẫu 03
|
||||
(re.compile(r'^1\.\s*Tên sáng kiến'), 'initiativeName03'),
|
||||
(re.compile(r'^2\.\s*Tác giả chính'), 'mainAuthor03'),
|
||||
(re.compile(r'^Chức vụ, đơn vị công tác'), 'position03'),
|
||||
# Mẫu 04
|
||||
(re.compile(r'^Kết luận'), 'conclusion'),
|
||||
]
|
||||
|
||||
# Dots-line pattern — paragraph text (stripped, whitespace collapsed) is 50+ dots
|
||||
DOTS_PATTERN = re.compile(r'^[\s\.]{50,}$')
|
||||
|
||||
|
||||
def para_text(p):
|
||||
"""Concat all w:t text of paragraph p."""
|
||||
return ''.join((t.text or '') for t in p.iter(f'{W}t')).strip()
|
||||
|
||||
|
||||
def set_para_text(p, new_text):
|
||||
"""Replace paragraph's run text with single new_text. Keeps first run's properties."""
|
||||
# Find all runs
|
||||
runs = list(p.findall(f'{W}r'))
|
||||
if not runs:
|
||||
# No run — add one with text
|
||||
r = ET.SubElement(p, f'{W}r')
|
||||
t = ET.SubElement(r, f'{W}t')
|
||||
t.text = new_text
|
||||
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
return
|
||||
|
||||
# Remove all runs except first
|
||||
for r in runs[1:]:
|
||||
p.remove(r)
|
||||
|
||||
# Clear all <w:t> in first run, leave <w:rPr> intact
|
||||
first = runs[0]
|
||||
for t in list(first.findall(f'{W}t')):
|
||||
first.remove(t)
|
||||
# Remove all non-rPr, non-text children? Leave them alone (breaks etc).
|
||||
# Add new text element
|
||||
t = ET.SubElement(first, f'{W}t')
|
||||
t.text = new_text
|
||||
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
|
||||
|
||||
def append_placeholder_to_para(p, placeholder):
|
||||
"""Append text ' {{placeholder}}' to end of paragraph (new run)."""
|
||||
r = ET.SubElement(p, f'{W}r')
|
||||
t = ET.SubElement(r, f'{W}t')
|
||||
t.text = f' {{{{{placeholder}}}}}'
|
||||
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
|
||||
|
||||
def find_placeholder_for_label(text):
|
||||
for regex, placeholder in LABEL_TO_PLACEHOLDER:
|
||||
if regex.match(text):
|
||||
return placeholder
|
||||
return None
|
||||
|
||||
|
||||
def find_cover_label(text):
|
||||
for regex, placeholder in COVER_LABEL_APPEND:
|
||||
if regex.match(text):
|
||||
return placeholder
|
||||
return None
|
||||
|
||||
|
||||
def process(xml_path):
|
||||
tree = ET.parse(xml_path)
|
||||
root = tree.getroot()
|
||||
|
||||
paragraphs = list(root.iter(f'{W}p'))
|
||||
|
||||
prev_label_placeholder = None
|
||||
used_placeholders = set()
|
||||
cover_done = set() # only replace cover labels once (file has 2 cover pages)
|
||||
dots_counter = 0
|
||||
|
||||
for i, p in enumerate(paragraphs):
|
||||
text = para_text(p)
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# 1. Cover label — append placeholder
|
||||
cover_pl = find_cover_label(text)
|
||||
if cover_pl and cover_pl not in cover_done:
|
||||
append_placeholder_to_para(p, cover_pl)
|
||||
cover_done.add(cover_pl)
|
||||
prev_label_placeholder = None
|
||||
continue
|
||||
|
||||
# 2. Dots line → replace with placeholder from prev label
|
||||
if DOTS_PATTERN.match(text):
|
||||
dots_counter += 1
|
||||
if prev_label_placeholder and prev_label_placeholder not in used_placeholders:
|
||||
set_para_text(p, f'{{{{{prev_label_placeholder}}}}}')
|
||||
used_placeholders.add(prev_label_placeholder)
|
||||
prev_label_placeholder = None
|
||||
else:
|
||||
# extra dots line without matching label — tag with generic counter
|
||||
set_para_text(p, f'{{{{extra_{dots_counter}}}}}')
|
||||
continue
|
||||
|
||||
# 3. Label paragraph → remember placeholder for NEXT dots line
|
||||
label_pl = find_placeholder_for_label(text)
|
||||
if label_pl:
|
||||
prev_label_placeholder = label_pl
|
||||
continue
|
||||
|
||||
# 4. Other paragraph — reset label if it wasn't matched
|
||||
# Don't reset prev_label if current para is just description (italic note etc.)
|
||||
# Keep prev_label until we see dots or a new label
|
||||
|
||||
tree.write(xml_path, encoding='UTF-8', xml_declaration=True)
|
||||
return used_placeholders, dots_counter
|
||||
|
||||
|
||||
def main():
|
||||
repo_root = Path(__file__).parent.parent
|
||||
src = repo_root / 'src/Backend/DYD.Api/Templates/bao-cao-template-original.docx'
|
||||
dst = repo_root / 'src/Backend/DYD.Api/Templates/bao-cao-template.docx'
|
||||
|
||||
if not src.exists():
|
||||
print(f'ERROR: source template not found at {src}')
|
||||
sys.exit(1)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp = Path(tmpdir)
|
||||
# Unzip
|
||||
with zipfile.ZipFile(src, 'r') as z:
|
||||
z.extractall(tmp)
|
||||
doc_xml = tmp / 'word' / 'document.xml'
|
||||
|
||||
used, dots = process(doc_xml)
|
||||
print(f'Replaced {len(used)} placeholders from {dots} dots-lines.')
|
||||
print(f'Placeholders: {sorted(used)}')
|
||||
|
||||
# Rezip
|
||||
if dst.exists():
|
||||
dst.unlink()
|
||||
with zipfile.ZipFile(dst, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for path in tmp.rglob('*'):
|
||||
if path.is_file():
|
||||
zout.write(path, path.relative_to(tmp))
|
||||
|
||||
print(f'Wrote {dst}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on the server from repo root with the stack up:
|
||||
# bash scripts/check-prod-stack.sh
|
||||
# Verifies Postgres, MinIO, and be0 /health; be0 → postgres:5432 and minio:9000 reachability.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE=(docker compose --env-file "${ROOT}/.env" -f "${ROOT}/docker-compose.prod.yml")
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ ! -f "${ROOT}/.env" ]]; then
|
||||
printf 'Missing %s\n' "${ROOT}/.env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "${ROOT}/.env"
|
||||
set +a
|
||||
|
||||
echo "=== Compose ps ==="
|
||||
"${COMPOSE[@]}" ps
|
||||
|
||||
echo ""
|
||||
echo "=== Postgres (pg_isready inside postgres container) ==="
|
||||
"${COMPOSE[@]}" exec -T postgres pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}"
|
||||
|
||||
echo ""
|
||||
echo "=== MinIO health (inside minio container) ==="
|
||||
"${COMPOSE[@]}" exec -T minio curl -sf "http://127.0.0.1:9000/minio/health/live" >/dev/null
|
||||
echo "OK"
|
||||
|
||||
echo ""
|
||||
echo "=== be0 /health ==="
|
||||
"${COMPOSE[@]}" exec -T be0 curl -sf "http://127.0.0.1:4402/health" >/dev/null
|
||||
echo "OK"
|
||||
|
||||
echo ""
|
||||
echo "=== be0 → postgres:5432 (TCP) ==="
|
||||
"${COMPOSE[@]}" exec -T be0 python -c "import socket; s=socket.create_connection(('postgres',5432),3); s.close()"
|
||||
echo "OK"
|
||||
|
||||
echo ""
|
||||
echo "=== be0 → minio:9000 (HTTP) ==="
|
||||
"${COMPOSE[@]}" exec -T be0 curl -sf "http://minio:9000/minio/health/live" >/dev/null
|
||||
echo "OK"
|
||||
|
||||
echo ""
|
||||
echo "=== fe0 → be0 proxy env ==="
|
||||
"${COMPOSE[@]}" exec -T fe0 /bin/sh -c 'echo "VITE_DEV_PROXY_TARGET=${VITE_DEV_PROXY_TARGET:-<unset>}"'
|
||||
|
||||
echo ""
|
||||
printf 'Done. If be0 /health failed, read logs: docker compose --env-file .env -f docker-compose.prod.yml logs be0\n'
|
||||
printf 'Typical cloud issue: POSTGRES_PASSWORD in .env does not match the first-init Postgres volume — see docs/deploy-production-docker.md\n'
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy the Docker Compose production stack from the repo root.
|
||||
# Prerequisites: `.env` (from `.env.example`) on the SAME host/path as this repo.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/deploy-prod.sh # pull pinned images, build, up -d
|
||||
# ./scripts/deploy-prod.sh --no-pull # offline / cached images only
|
||||
#fdf
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE_ABS="${ROOT}/docker-compose.prod.yml"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
DO_PULL=1
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-pull) DO_PULL=0 ;;
|
||||
-h|--help)
|
||||
grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ ! -f "$ROOT/.env" ]]; then
|
||||
printf 'Missing %s\nRun: cp .env.example .env && edit secrets\n' "$ROOT/.env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "→ Validating prod environment..."
|
||||
"$ROOT/scripts/verify-prod-env.sh"
|
||||
|
||||
echo "→ Validating compose file..."
|
||||
docker compose --env-file "$ROOT/.env" -f "$COMPOSE_ABS" config >/dev/null
|
||||
|
||||
if [[ "$DO_PULL" -eq 1 ]]; then
|
||||
echo "→ Pulling images..."
|
||||
docker compose --env-file "$ROOT/.env" -f "$COMPOSE_ABS" pull
|
||||
fi
|
||||
|
||||
echo "→ Building images..."
|
||||
docker compose --env-file "$ROOT/.env" -f "$COMPOSE_ABS" build
|
||||
|
||||
echo "→ Starting stack..."
|
||||
docker compose --env-file "$ROOT/.env" -f "$COMPOSE_ABS" up -d --remove-orphans
|
||||
|
||||
echo "→ Status"
|
||||
docker compose --env-file "$ROOT/.env" -f "$COMPOSE_ABS" ps
|
||||
|
||||
printf '\nDeployed. Logs: docker compose --env-file .env -f docker-compose.prod.yml logs -f be0\n'
|
||||
printf 'Connectivity: bash scripts/check-prod-stack.sh\n'
|
||||
printf 'Postgres auth is FIXED on first DB init — docs/deploy-production-docker.md\n'
|
||||
@@ -0,0 +1,53 @@
|
||||
# ============================================================================
|
||||
# DYD — Deployment environment variables (TEMPLATE)
|
||||
#
|
||||
# Copy file này thành .env.deploy.local (đã trong .gitignore) và điền secrets.
|
||||
# KHÔNG commit file .env.deploy.local vào git.
|
||||
# ============================================================================
|
||||
|
||||
# --- VPS ---------------------------------------------------------------------
|
||||
VPS_IP=103.124.94.58
|
||||
VPS_USER=Administrator
|
||||
# VPS_PASSWORD=... # Không khuyến khích lưu file — nhập khi RDP
|
||||
|
||||
# --- DOMAINS -----------------------------------------------------------------
|
||||
API_DOMAIN=api.ski-ump.com.vn
|
||||
USER_DOMAIN=ski-ump.com.vn
|
||||
ADMIN_DOMAIN=admin.ski-ump.com.vn
|
||||
|
||||
# --- SQL SERVER --------------------------------------------------------------
|
||||
SQL_SERVER=103.124.94.58,1433
|
||||
SQL_DATABASE=DYD_Prod
|
||||
|
||||
# Login admin (chỉ để SETUP lần đầu — KHÔNG dùng cho app)
|
||||
# SQL_SA_USER=sa
|
||||
# SQL_SA_PASSWORD=...
|
||||
|
||||
# Login app (tạo bằng scripts/deployment/sql/01-create-database.sql)
|
||||
SQL_APP_USER=dyd_app
|
||||
# SQL_APP_PASSWORD=... # Generate: [System.Web.Security.Membership]::GeneratePassword(32,8)
|
||||
|
||||
# Connection string cho .NET (KHÔNG commit)
|
||||
# DB_CONNECTION_STRING="Server=103.124.94.58,1433;Database=DYD_Prod;User Id=dyd_app;Password=XXX;TrustServerCertificate=True;MultipleActiveResultSets=True;"
|
||||
|
||||
# --- JWT ---------------------------------------------------------------------
|
||||
# Generate: [Convert]::ToBase64String([byte[]]::new(48)) sau [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
|
||||
# Hoặc online: https://generate-random.org/api-token-generator (64 char)
|
||||
# JWT_SIGNING_KEY=...
|
||||
|
||||
# --- AI SERVICE --------------------------------------------------------------
|
||||
AI_SERVICE_URL=http://localhost:4402
|
||||
# AI_SERVICE_API_KEY=... # Random hex 32: [BitConverter]::ToString((New-Object byte[] 32).Tap({[Security.Cryptography.RandomNumberGenerator]::Fill($_)})) -replace '-'
|
||||
|
||||
# --- GITEA -------------------------------------------------------------------
|
||||
GITEA_URL=http://103.124.94.58:3000
|
||||
# GITEA_ADMIN_USER=admin
|
||||
# GITEA_ADMIN_PASSWORD=... # Set qua web installer
|
||||
# GITEA_RUNNER_TOKEN=... # Lấy ở Site Admin → Actions → Runner Management
|
||||
|
||||
# --- EMAIL (for win-acme Let's Encrypt + SMTP future) -----------------------
|
||||
ADMIN_EMAIL=admin@ski-ump.com.vn
|
||||
# SMTP_HOST=smtp.sendgrid.net
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=apikey
|
||||
# SMTP_PASSWORD=...
|
||||
@@ -0,0 +1,25 @@
|
||||
# ============================================================================
|
||||
# COPY TOÀN BỘ block PowerShell BÊN DƯỚI, paste vào PowerShell Admin trên VPS
|
||||
# (Nhỏ gọn ~4KB, clipboard RDP paste tốt)
|
||||
# ============================================================================
|
||||
|
||||
$ErrorActionPreference='Stop'; Write-Host "[DYD SSH] Start..." -F Cyan
|
||||
$cap = Get-WindowsCapability -Online | ? Name -like 'OpenSSH.Server*'
|
||||
if ($cap.State -ne 'Installed') { Add-WindowsCapability -Online -Name $cap.Name | Out-Null; Write-Host "[OK] OpenSSH installed" -F Green } else { Write-Host "[OK] OpenSSH already installed" -F Green }
|
||||
Start-Service sshd; Set-Service sshd -StartupType Automatic; Set-Service ssh-agent -StartupType Automatic; Start-Service ssh-agent -ErrorAction SilentlyContinue
|
||||
if (-not (Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue)) { New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH SSH Server' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null }
|
||||
New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' -Name DefaultShell -Value 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' -PropertyType String -Force | Out-Null
|
||||
$pub = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM/SmlEVa41JmeIAwQOtEkdzUo1BLPJbJ+oDqDYm1ywQ dyd-vps-deploy-20260415'
|
||||
$authFile = 'C:\ProgramData\ssh\administrators_authorized_keys'
|
||||
if (-not (Test-Path (Split-Path $authFile))) { New-Item -ItemType Directory -Path (Split-Path $authFile) -Force | Out-Null }
|
||||
$existing = if (Test-Path $authFile) { Get-Content $authFile -Raw } else { '' }
|
||||
if ($existing -notmatch [regex]::Escape($pub)) { Add-Content -Path $authFile -Value $pub -Encoding UTF8 }
|
||||
icacls $authFile /inheritance:r | Out-Null; icacls $authFile /grant 'Administrators:F' /grant 'SYSTEM:F' | Out-Null
|
||||
Restart-Service sshd
|
||||
$sshd = Get-Service sshd; $listen = Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue
|
||||
Write-Host ""; Write-Host "=====================================" -F Green; Write-Host " DONE — SSH server ready" -F Green; Write-Host "=====================================" -F Green
|
||||
Write-Host " sshd status : $($sshd.Status)" -F Green
|
||||
Write-Host " port 22 : $(if($listen){'LISTENING'}else{'NOT listening'})" -F $(if($listen){'Green'}else{'Red'})
|
||||
Write-Host " public key : added to $authFile" -F Green
|
||||
Write-Host ""; Write-Host "Dev can test:" -F Yellow
|
||||
Write-Host " ssh -i ~/.ssh/dyd_vps Administrator@103.124.94.58 hostname" -F Yellow
|
||||
@@ -0,0 +1,143 @@
|
||||
# ============================================================================
|
||||
# 00 — Enable OpenSSH Server trên Windows Server
|
||||
# USAGE:
|
||||
# 1. RDP vào VPS (103.124.94.58:3389)
|
||||
# 2. Mở PowerShell AS ADMINISTRATOR
|
||||
# 3. Copy-paste TOÀN BỘ file này vào PowerShell rồi Enter
|
||||
# 4. Chờ ~1 phút, script sẽ print "DONE" khi xong
|
||||
# 5. Báo lại cho dev để test SSH
|
||||
#
|
||||
# Script idempotent — chạy nhiều lần OK.
|
||||
# ============================================================================
|
||||
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=========================================================" -ForegroundColor Cyan
|
||||
Write-Host " DYD — Enable OpenSSH Server on Windows" -ForegroundColor Cyan
|
||||
Write-Host "=========================================================" -ForegroundColor Cyan
|
||||
|
||||
# --- 1. Install OpenSSH Server capability ---
|
||||
Write-Host ""
|
||||
Write-Host "[1/6] Install OpenSSH.Server capability ..." -ForegroundColor Yellow
|
||||
$cap = Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH.Server*'
|
||||
if ($cap.State -ne 'Installed') {
|
||||
Add-WindowsCapability -Online -Name $cap.Name | Out-Null
|
||||
Write-Host " [OK] Installed" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " [OK] Already installed" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- 2. Start sshd + auto-start ---
|
||||
Write-Host ""
|
||||
Write-Host "[2/6] Start sshd service ..." -ForegroundColor Yellow
|
||||
Start-Service sshd
|
||||
Set-Service -Name sshd -StartupType Automatic
|
||||
# Start ssh-agent too (tùy, cho key management)
|
||||
Set-Service -Name ssh-agent -StartupType Automatic
|
||||
Start-Service ssh-agent -ErrorAction SilentlyContinue
|
||||
Write-Host " [OK] sshd running, auto-start enabled" -ForegroundColor Green
|
||||
|
||||
# --- 3. Firewall rule port 22 ---
|
||||
Write-Host ""
|
||||
Write-Host "[3/6] Firewall rule port 22 ..." -ForegroundColor Yellow
|
||||
$rule = Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue
|
||||
if (-not $rule) {
|
||||
New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' `
|
||||
-DisplayName 'OpenSSH SSH Server (sshd)' `
|
||||
-Enabled True -Direction Inbound -Protocol TCP -Action Allow `
|
||||
-LocalPort 22 | Out-Null
|
||||
Write-Host " [OK] Firewall rule created" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Enable-NetFirewallRule -Name 'OpenSSH-Server-In-TCP'
|
||||
Write-Host " [OK] Firewall rule enabled" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- 4. Set DefaultShell = PowerShell (thay cho cmd) ---
|
||||
Write-Host ""
|
||||
Write-Host "[4/6] Set DefaultShell = PowerShell ..." -ForegroundColor Yellow
|
||||
New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' `
|
||||
-Name DefaultShell `
|
||||
-Value 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' `
|
||||
-PropertyType String -Force | Out-Null
|
||||
Write-Host " [OK] DefaultShell set to PowerShell" -ForegroundColor Green
|
||||
|
||||
# --- 5. Add dev machine public key to authorized_keys ---
|
||||
Write-Host ""
|
||||
Write-Host "[5/6] Add dev public key ..." -ForegroundColor Yellow
|
||||
|
||||
# === PUBLIC KEY ĐÃ EMBED — KHÔNG commit private key ===
|
||||
$PublicKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM/SmlEVa41JmeIAwQOtEkdzUo1BLPJbJ+oDqDYm1ywQ dyd-vps-deploy-20260415'
|
||||
|
||||
# For Administrator account, dùng C:\ProgramData\ssh\administrators_authorized_keys
|
||||
# (KHÔNG dùng ~/.ssh/authorized_keys)
|
||||
$authFile = 'C:\ProgramData\ssh\administrators_authorized_keys'
|
||||
|
||||
# Ensure directory exists
|
||||
$authDir = Split-Path $authFile
|
||||
if (-not (Test-Path $authDir)) {
|
||||
New-Item -ItemType Directory -Path $authDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Append key nếu chưa có (idempotent)
|
||||
$existing = if (Test-Path $authFile) { Get-Content $authFile -Raw } else { '' }
|
||||
if ($existing -notmatch [regex]::Escape($PublicKey)) {
|
||||
Add-Content -Path $authFile -Value $PublicKey -Encoding UTF8
|
||||
Write-Host " [OK] Public key added" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " [OK] Public key already present" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Fix permission — QUAN TRỌNG, sai permission = SSH silently reject key
|
||||
# Chỉ Administrators + SYSTEM được đọc
|
||||
icacls $authFile /inheritance:r | Out-Null
|
||||
icacls $authFile /grant 'Administrators:F' /grant 'SYSTEM:F' | Out-Null
|
||||
Write-Host " [OK] Permission locked (Admin + SYSTEM only)" -ForegroundColor Green
|
||||
|
||||
# --- 6. Verify ---
|
||||
Write-Host ""
|
||||
Write-Host "[6/6] Verify ..." -ForegroundColor Yellow
|
||||
|
||||
$sshd = Get-Service sshd
|
||||
if ($sshd.Status -eq 'Running') {
|
||||
Write-Host " [OK] sshd: Running" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " [FAIL] sshd: $($sshd.Status)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
# Test listener
|
||||
$listening = Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue
|
||||
if ($listening) {
|
||||
Write-Host " [OK] Port 22: listening on $($listening[0].LocalAddress)" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " [WARN] Port 22: not listening (may need restart)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Test firewall
|
||||
$fw = Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue
|
||||
if ($fw -and $fw.Enabled -eq 'True') {
|
||||
Write-Host " [OK] Firewall: allowed" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- DONE ---
|
||||
Write-Host ""
|
||||
Write-Host "=========================================================" -ForegroundColor Green
|
||||
Write-Host " DONE — SSH server ready" -ForegroundColor Green
|
||||
Write-Host "=========================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Dev machine can now connect:"
|
||||
Write-Host " ssh -i ~/.ssh/dyd_vps Administrator@103.124.94.58" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host "Test từ máy dev:"
|
||||
Write-Host " ssh -i ~/.ssh/dyd_vps Administrator@103.124.94.58 hostname" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host "[!] Nếu đổi ý muốn disable SSH sau, chạy:"
|
||||
Write-Host " Stop-Service sshd; Set-Service sshd -StartupType Disabled"
|
||||
@@ -0,0 +1,167 @@
|
||||
# ============================================================================
|
||||
# 01 — Install prerequisites on Windows Server (Run as Administrator)
|
||||
# Target: Windows Server 2019/2022 with IIS
|
||||
# Usage: Run via RDP on VPS (103.124.94.58)
|
||||
# ============================================================================
|
||||
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
function Write-Step($msg) {
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " $msg" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Test-CommandExists($cmd) {
|
||||
$null -ne (Get-Command $cmd -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "1/7 — Enable IIS features"
|
||||
# ---------------------------------------------------------------------------
|
||||
$features = @(
|
||||
'IIS-WebServerRole', 'IIS-WebServer', 'IIS-CommonHttpFeatures',
|
||||
'IIS-HttpErrors', 'IIS-HttpRedirect', 'IIS-StaticContent',
|
||||
'IIS-DefaultDocument', 'IIS-DirectoryBrowsing',
|
||||
'IIS-ApplicationDevelopment', 'IIS-ISAPIExtensions', 'IIS-ISAPIFilter',
|
||||
'IIS-Security', 'IIS-RequestFiltering', 'IIS-BasicAuthentication',
|
||||
'IIS-WindowsAuthentication', 'IIS-HealthAndDiagnostics', 'IIS-HttpLogging',
|
||||
'IIS-LoggingLibraries', 'IIS-Performance', 'IIS-HttpCompressionStatic',
|
||||
'IIS-HttpCompressionDynamic', 'IIS-WebServerManagementTools',
|
||||
'IIS-ManagementConsole', 'IIS-ManagementScriptingTools',
|
||||
'IIS-WebSockets', 'NetFx4Extended-ASPNET45',
|
||||
'IIS-ASPNET45', 'IIS-NetFxExtensibility45'
|
||||
)
|
||||
|
||||
foreach ($feature in $features) {
|
||||
$state = (Get-WindowsOptionalFeature -Online -FeatureName $feature -ErrorAction SilentlyContinue).State
|
||||
if ($state -ne 'Enabled') {
|
||||
Write-Host " Enabling $feature ..." -ForegroundColor Yellow
|
||||
Enable-WindowsOptionalFeature -Online -FeatureName $feature -NoRestart -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
else {
|
||||
Write-Host " [OK] $feature" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "2/7 — Install ASP.NET Core 10 Hosting Bundle"
|
||||
# ---------------------------------------------------------------------------
|
||||
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\ASP.NET Core\Hosting Bundle\v10.0') {
|
||||
Write-Host " [OK] ASP.NET Core 10 Hosting Bundle already installed" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
$url = 'https://builds.dotnet.microsoft.com/dotnet/Runtime/10.0.6/dotnet-hosting-10.0.6-win.exe'
|
||||
$exe = "$env:TEMP\dotnet-hosting-10.0.6-win.exe"
|
||||
Write-Host " Downloading $url ..." -ForegroundColor Yellow
|
||||
Invoke-WebRequest -Uri $url -OutFile $exe
|
||||
Write-Host " Installing (silent)..." -ForegroundColor Yellow
|
||||
Start-Process -FilePath $exe -ArgumentList '/quiet','/install','/norestart' -Wait
|
||||
Remove-Item $exe -Force
|
||||
Write-Host " [OK] ASP.NET Core 10 Hosting Bundle installed" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "3/7 — Install IIS URL Rewrite 2.1"
|
||||
# ---------------------------------------------------------------------------
|
||||
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\IIS Extensions\URL Rewrite') {
|
||||
Write-Host " [OK] URL Rewrite already installed" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
$url = 'https://download.microsoft.com/download/1/2/8/128E2E22-C1B9-44A4-BE2A-5859ED1D4592/rewrite_amd64_en-US.msi'
|
||||
$msi = "$env:TEMP\rewrite_amd64_en-US.msi"
|
||||
Write-Host " Downloading URL Rewrite ..." -ForegroundColor Yellow
|
||||
Invoke-WebRequest -Uri $url -OutFile $msi
|
||||
Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /quiet /norestart" -Wait
|
||||
Remove-Item $msi -Force
|
||||
Write-Host " [OK] URL Rewrite installed" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "4/7 — Install .NET 10 SDK (for Gitea runner builds)"
|
||||
# ---------------------------------------------------------------------------
|
||||
if (Test-CommandExists 'dotnet') {
|
||||
$v = (dotnet --list-sdks) -match '^10\.'
|
||||
if ($v) {
|
||||
Write-Host " [OK] .NET 10 SDK installed" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
if (-not (Test-CommandExists 'dotnet') -or -not ((dotnet --list-sdks) -match '^10\.')) {
|
||||
$url = 'https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.104/dotnet-sdk-10.0.104-win-x64.exe'
|
||||
$exe = "$env:TEMP\dotnet-sdk-10.0.104-win-x64.exe"
|
||||
Write-Host " Downloading .NET 10 SDK ..." -ForegroundColor Yellow
|
||||
Invoke-WebRequest -Uri $url -OutFile $exe
|
||||
Start-Process $exe -ArgumentList '/quiet','/norestart' -Wait
|
||||
Remove-Item $exe -Force
|
||||
Write-Host " [OK] .NET 10 SDK installed" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "5/7 — Install Node.js 20 LTS"
|
||||
# ---------------------------------------------------------------------------
|
||||
if (Test-CommandExists 'node') {
|
||||
$nodeV = (node --version) -replace 'v', ''
|
||||
if ([version]$nodeV -ge [version]'20.0.0') {
|
||||
Write-Host " [OK] Node.js $nodeV installed" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
if (-not (Test-CommandExists 'node') -or ([version]((node --version) -replace 'v','')) -lt [version]'20.0.0') {
|
||||
$url = 'https://nodejs.org/dist/v20.18.0/node-v20.18.0-x64.msi'
|
||||
$msi = "$env:TEMP\node-v20.18.0-x64.msi"
|
||||
Write-Host " Downloading Node.js 20 ..." -ForegroundColor Yellow
|
||||
Invoke-WebRequest -Uri $url -OutFile $msi
|
||||
Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /quiet /norestart" -Wait
|
||||
Remove-Item $msi -Force
|
||||
Write-Host " [OK] Node.js 20 installed" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "6/7 — Install Git for Windows"
|
||||
# ---------------------------------------------------------------------------
|
||||
if (Test-CommandExists 'git') {
|
||||
Write-Host " [OK] Git installed" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
$url = 'https://github.com/git-for-windows/git/releases/download/v2.46.0.windows.1/Git-2.46.0-64-bit.exe'
|
||||
$exe = "$env:TEMP\Git-2.46.0-64-bit.exe"
|
||||
Write-Host " Downloading Git ..." -ForegroundColor Yellow
|
||||
Invoke-WebRequest -Uri $url -OutFile $exe
|
||||
Start-Process $exe -ArgumentList '/VERYSILENT','/NORESTART' -Wait
|
||||
Remove-Item $exe -Force
|
||||
Write-Host " [OK] Git installed" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "7/7 — Install win-acme (Let's Encrypt client)"
|
||||
# ---------------------------------------------------------------------------
|
||||
$wacsPath = 'C:\Tools\win-acme'
|
||||
if (Test-Path "$wacsPath\wacs.exe") {
|
||||
Write-Host " [OK] win-acme already installed at $wacsPath" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
New-Item -ItemType Directory -Force -Path $wacsPath | Out-Null
|
||||
$url = 'https://github.com/win-acme/win-acme/releases/download/v2.2.9.1701/win-acme.v2.2.9.1701.x64.pluggable.zip'
|
||||
$zip = "$env:TEMP\wacs.zip"
|
||||
Write-Host " Downloading win-acme ..." -ForegroundColor Yellow
|
||||
Invoke-WebRequest -Uri $url -OutFile $zip
|
||||
Expand-Archive -Path $zip -DestinationPath $wacsPath -Force
|
||||
Remove-Item $zip -Force
|
||||
Write-Host " [OK] win-acme installed at $wacsPath\wacs.exe" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "=========================================================" -ForegroundColor Green
|
||||
Write-Host " DONE — Prerequisites installed" -ForegroundColor Green
|
||||
Write-Host "=========================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Next steps:"
|
||||
Write-Host " 1. Restart IIS: iisreset"
|
||||
Write-Host " 2. Run: .\scripts\deployment\02-setup-sqlserver.ps1"
|
||||
Write-Host " 3. Run: .\scripts\deployment\03-setup-iis-sites.ps1"
|
||||
Write-Host ""
|
||||
Write-Host "Refresh PATH in new PowerShell session to use dotnet/node/git"
|
||||
@@ -0,0 +1,159 @@
|
||||
# ============================================================================
|
||||
# 03 — Setup 3 IIS sites: DYD.Api / DYD.User / DYD.Admin
|
||||
# Run as Administrator on VPS after 01-install-prerequisites.ps1
|
||||
# ============================================================================
|
||||
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Import-Module WebAdministration -ErrorAction Stop
|
||||
|
||||
# --- CONFIG ------------------------------------------------------------------
|
||||
$BaseDir = 'C:\inetpub'
|
||||
$Sites = @(
|
||||
@{
|
||||
Name = 'DYD.Api'
|
||||
AppPool = 'DYD.ApiPool'
|
||||
Path = "$BaseDir\DYD.Api"
|
||||
HttpPort = 5443
|
||||
HttpsPort = 443
|
||||
Host = 'api.ski-ump.com.vn'
|
||||
LogsPath = "$BaseDir\DYD.Api\logs"
|
||||
Managed = $true # .NET Core → No Managed Code
|
||||
},
|
||||
@{
|
||||
Name = 'DYD.User'
|
||||
AppPool = '' # static site, no app pool
|
||||
Path = "$BaseDir\DYD.User"
|
||||
HttpPort = 8080
|
||||
HttpsPort = 443
|
||||
Host = 'ski-ump.com.vn'
|
||||
Managed = $false
|
||||
},
|
||||
@{
|
||||
Name = 'DYD.Admin'
|
||||
AppPool = ''
|
||||
Path = "$BaseDir\DYD.Admin"
|
||||
HttpPort = 8082
|
||||
HttpsPort = 443
|
||||
Host = 'admin.ski-ump.com.vn'
|
||||
Managed = $false
|
||||
}
|
||||
)
|
||||
|
||||
# --- HELPER ------------------------------------------------------------------
|
||||
function New-Dir($p) {
|
||||
if (-not (Test-Path $p)) {
|
||||
New-Item -ItemType Directory -Path $p -Force | Out-Null
|
||||
Write-Host " Created: $p" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
function New-ApiAppPool($name) {
|
||||
if (Test-Path "IIS:\AppPools\$name") {
|
||||
Write-Host " [OK] AppPool $name exists" -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
New-WebAppPool -Name $name | Out-Null
|
||||
Set-ItemProperty "IIS:\AppPools\$name" -name managedRuntimeVersion -value ''
|
||||
Set-ItemProperty "IIS:\AppPools\$name" -name startMode -value 'AlwaysRunning'
|
||||
Set-ItemProperty "IIS:\AppPools\$name" -name processModel.idleTimeout -value ([TimeSpan]::Zero)
|
||||
Write-Host " Created AppPool: $name (No Managed Code, AlwaysRunning)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- DIRECTORIES -------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " 1/4 — Create directories" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
foreach ($s in $Sites) {
|
||||
New-Dir $s.Path
|
||||
if ($s.LogsPath) { New-Dir $s.LogsPath }
|
||||
}
|
||||
New-Dir "$BaseDir\backups"
|
||||
|
||||
# --- APP POOLS ---------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " 2/4 — Create App Pools" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
foreach ($s in $Sites) {
|
||||
if ($s.Managed -and $s.AppPool) {
|
||||
New-ApiAppPool $s.AppPool
|
||||
}
|
||||
}
|
||||
|
||||
# --- SITES -------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " 3/4 — Create Sites + Bindings" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
foreach ($s in $Sites) {
|
||||
# Remove existing site if port conflict
|
||||
$existing = Get-Website -Name $s.Name -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Host " Site $($s.Name) exists. Skipping creation." -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
|
||||
if ($s.Managed) {
|
||||
New-Website -Name $s.Name `
|
||||
-PhysicalPath $s.Path `
|
||||
-ApplicationPool $s.AppPool `
|
||||
-Port $s.HttpPort `
|
||||
-HostHeader $s.Host `
|
||||
-Force | Out-Null
|
||||
}
|
||||
else {
|
||||
New-Website -Name $s.Name `
|
||||
-PhysicalPath $s.Path `
|
||||
-Port $s.HttpPort `
|
||||
-HostHeader $s.Host `
|
||||
-Force | Out-Null
|
||||
}
|
||||
Write-Host " Created site: $($s.Name) on :$($s.HttpPort) — host: $($s.Host)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- PLACEHOLDER INDEX -------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " 4/4 — Placeholder index.html" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
foreach ($s in $Sites) {
|
||||
$indexPath = Join-Path $s.Path 'index.html'
|
||||
if (-not (Test-Path $indexPath)) {
|
||||
@"
|
||||
<!DOCTYPE html>
|
||||
<html><head><title>$($s.Name)</title></head>
|
||||
<body><h1>$($s.Name) — placeholder</h1>
|
||||
<p>Site chưa được deploy. Chạy pipeline hoặc deploy thủ công.</p></body></html>
|
||||
"@ | Set-Content $indexPath -Encoding UTF8
|
||||
}
|
||||
}
|
||||
|
||||
# --- FIREWALL ----------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "Firewall rules (HTTP 80, HTTPS 443, custom ports):" -ForegroundColor Cyan
|
||||
$ports = @(80, 443, 5443, 8080, 8082, 3000) # 3000 cho Gitea sau
|
||||
foreach ($p in $ports) {
|
||||
$rule = "DYD-TCP-$p"
|
||||
if (-not (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue)) {
|
||||
New-NetFirewallRule -DisplayName $rule -Direction Inbound -Protocol TCP -LocalPort $p -Action Allow | Out-Null
|
||||
Write-Host " [+] TCP $p allowed" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# --- SUMMARY -----------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "=========================================================" -ForegroundColor Green
|
||||
Write-Host " DONE — 3 IIS sites ready" -ForegroundColor Green
|
||||
Write-Host "=========================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Get-Website | Where-Object { $_.Name -like 'DYD.*' } | Format-Table -AutoSize Name, State, PhysicalPath, @{L='Bindings';E={($_.Bindings.Collection.bindingInformation) -join ', '}}
|
||||
|
||||
Write-Host "HTTP URLs (cần bind host file hoặc DNS):"
|
||||
foreach ($s in $Sites) {
|
||||
Write-Host " http://$($s.Host):$($s.HttpPort)" -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Next: 04-install-gitea.ps1 + 05-setup-ssl.ps1 (sau khi DNS trỏ IP)"
|
||||
@@ -0,0 +1,147 @@
|
||||
# ============================================================================
|
||||
# 04 — Install Gitea 1.23 + NSSM service (port 3000, SQLite)
|
||||
# Run as Administrator. Pattern từ NamGroup skill cicd-gitea-windows.
|
||||
# ============================================================================
|
||||
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$GiteaDir = 'C:\Gitea'
|
||||
$DataDir = 'C:\Gitea\data'
|
||||
$LogsDir = 'C:\Gitea\log'
|
||||
$NSSMDir = 'C:\Tools\nssm'
|
||||
|
||||
# --- DOWNLOAD GITEA ---------------------------------------------------------
|
||||
Write-Host "1/5 — Download Gitea 1.23.7" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $GiteaDir, $DataDir, $LogsDir | Out-Null
|
||||
|
||||
$giteaExe = "$GiteaDir\gitea.exe"
|
||||
if (-not (Test-Path $giteaExe)) {
|
||||
$url = 'https://dl.gitea.com/gitea/1.23.7/gitea-1.23.7-windows-4.0-amd64.exe'
|
||||
Invoke-WebRequest -Uri $url -OutFile $giteaExe
|
||||
Write-Host " [OK] gitea.exe downloaded" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " [OK] gitea.exe exists" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- DOWNLOAD NSSM ----------------------------------------------------------
|
||||
Write-Host "2/5 — Download NSSM" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $NSSMDir | Out-Null
|
||||
|
||||
$nssmExe = "$NSSMDir\nssm.exe"
|
||||
if (-not (Test-Path $nssmExe)) {
|
||||
$zip = "$env:TEMP\nssm.zip"
|
||||
Invoke-WebRequest -Uri 'https://nssm.cc/release/nssm-2.24.zip' -OutFile $zip
|
||||
Expand-Archive -Path $zip -DestinationPath "$env:TEMP\nssm-extract" -Force
|
||||
Copy-Item "$env:TEMP\nssm-extract\nssm-2.24\win64\nssm.exe" $nssmExe -Force
|
||||
Remove-Item $zip, "$env:TEMP\nssm-extract" -Recurse -Force
|
||||
Write-Host " [OK] nssm.exe ready" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " [OK] nssm.exe exists" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- CONFIG FILE -------------------------------------------------------------
|
||||
Write-Host "3/5 — Create app.ini" -ForegroundColor Cyan
|
||||
$appIni = "$GiteaDir\custom\conf\app.ini"
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $appIni) | Out-Null
|
||||
|
||||
if (-not (Test-Path $appIni)) {
|
||||
@'
|
||||
APP_NAME = DYD Git (Gitea)
|
||||
RUN_USER = LOCAL SYSTEM
|
||||
RUN_MODE = prod
|
||||
|
||||
[server]
|
||||
PROTOCOL = http
|
||||
DOMAIN = 103.124.94.58
|
||||
HTTP_ADDR = 127.0.0.1
|
||||
HTTP_PORT = 3000
|
||||
ROOT_URL = http://103.124.94.58:3000/
|
||||
DISABLE_SSH = true
|
||||
OFFLINE_MODE = false
|
||||
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
PATH = C:/Gitea/data/gitea.db
|
||||
LOG_SQL = false
|
||||
|
||||
[repository]
|
||||
ROOT = C:/Gitea/data/gitea-repositories
|
||||
|
||||
[log]
|
||||
MODE = file
|
||||
LEVEL = info
|
||||
ROOT_PATH = C:/Gitea/log
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = false
|
||||
; SECRET_KEY sẽ được tự sinh khi chạy lần đầu qua web installer
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = true
|
||||
REQUIRE_SIGNIN_VIEW = true
|
||||
ENABLE_NOTIFY_MAIL = false
|
||||
REGISTER_EMAIL_CONFIRM = false
|
||||
|
||||
[picture]
|
||||
DISABLE_GRAVATAR = true
|
||||
|
||||
[actions]
|
||||
ENABLED = true
|
||||
'@ | Set-Content -Path $appIni -Encoding UTF8
|
||||
Write-Host " [OK] app.ini created" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " [OK] app.ini exists (skip)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- INSTALL SERVICE --------------------------------------------------------
|
||||
Write-Host "4/5 — Install NSSM service 'gitea'" -ForegroundColor Cyan
|
||||
$existingService = Get-Service -Name 'gitea' -ErrorAction SilentlyContinue
|
||||
if (-not $existingService) {
|
||||
& $nssmExe install gitea $giteaExe web --config "$appIni" --work-path "$GiteaDir"
|
||||
& $nssmExe set gitea AppStdout "$LogsDir\stdout.log"
|
||||
& $nssmExe set gitea AppStderr "$LogsDir\stderr.log"
|
||||
& $nssmExe set gitea AppRotateFiles 1
|
||||
& $nssmExe set gitea AppRotateBytes 10485760
|
||||
& $nssmExe set gitea DisplayName 'Gitea Service (DYD)'
|
||||
& $nssmExe set gitea Description 'Gitea self-hosted git + Actions for DYD project'
|
||||
& $nssmExe set gitea Start SERVICE_AUTO_START
|
||||
Write-Host " [OK] Service installed" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " [OK] Service 'gitea' exists" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- START -------------------------------------------------------------------
|
||||
Write-Host "5/5 — Start Gitea service" -ForegroundColor Cyan
|
||||
Start-Service gitea
|
||||
Start-Sleep 5
|
||||
|
||||
$svc = Get-Service gitea
|
||||
Write-Host " Service status: $($svc.Status)" -ForegroundColor $(if ($svc.Status -eq 'Running') {'Green'} else {'Red'})
|
||||
|
||||
# --- Test HTTP ---------------------------------------------------------------
|
||||
try {
|
||||
$r = Invoke-WebRequest 'http://127.0.0.1:3000' -UseBasicParsing -TimeoutSec 10
|
||||
Write-Host " Gitea web responds: HTTP $($r.StatusCode)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " [WARN] Gitea chưa response, check log: $LogsDir" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# --- SUMMARY -----------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "=========================================================" -ForegroundColor Green
|
||||
Write-Host " Gitea installed" -ForegroundColor Green
|
||||
Write-Host "=========================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Next steps MANUAL:" -ForegroundColor Yellow
|
||||
Write-Host " 1. Mở browser: http://103.124.94.58:3000"
|
||||
Write-Host " 2. Complete installer wizard (SQLite OK, admin account)"
|
||||
Write-Host " 3. Sau khi setup: Site Admin → Disable Registration"
|
||||
Write-Host " 4. Tạo repo 'DYD' và push code"
|
||||
Write-Host " 5. Site Admin → Actions → Runner Management → Create Runner"
|
||||
Write-Host " → copy registration token → chạy 05-install-act-runner.ps1"
|
||||
Write-Host ""
|
||||
Write-Host "Config file: $appIni"
|
||||
Write-Host "Logs: $LogsDir"
|
||||
@@ -0,0 +1,98 @@
|
||||
# ============================================================================
|
||||
# 05 — Install act_runner (Gitea Actions runner) as Windows service
|
||||
# PREREQUISITE:
|
||||
# 1. Gitea đã chạy (04-install-gitea.ps1)
|
||||
# 2. Đã lấy registration token từ Gitea:
|
||||
# Site Admin → Actions → Runner Management → Create new Runner → Copy token
|
||||
# Usage:
|
||||
# .\05-install-act-runner.ps1 -Token <registration-token>
|
||||
# ============================================================================
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Token,
|
||||
|
||||
[string]$GiteaUrl = 'http://127.0.0.1:3000',
|
||||
[string]$RunnerName = 'dyd-windows-runner',
|
||||
[string]$Labels = 'windows:host,windows-latest:host'
|
||||
)
|
||||
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$RunnerDir = 'C:\Tools\act_runner'
|
||||
$NSSM = 'C:\Tools\nssm\nssm.exe'
|
||||
|
||||
if (-not (Test-Path $NSSM)) {
|
||||
Write-Error "NSSM chưa cài. Chạy 04-install-gitea.ps1 trước."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- DOWNLOAD ---------------------------------------------------------------
|
||||
Write-Host "1/4 — Download act_runner" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $RunnerDir | Out-Null
|
||||
|
||||
$runnerExe = "$RunnerDir\act_runner.exe"
|
||||
if (-not (Test-Path $runnerExe)) {
|
||||
$url = 'https://dl.gitea.com/act_runner/0.2.11/act_runner-0.2.11-windows-amd64.exe'
|
||||
Invoke-WebRequest -Uri $url -OutFile $runnerExe
|
||||
Write-Host " [OK] downloaded" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- REGISTER ---------------------------------------------------------------
|
||||
Write-Host "2/4 — Register with Gitea" -ForegroundColor Cyan
|
||||
Set-Location $RunnerDir
|
||||
if (-not (Test-Path "$RunnerDir\.runner")) {
|
||||
& $runnerExe register --no-interactive `
|
||||
--instance $GiteaUrl `
|
||||
--token $Token `
|
||||
--name $RunnerName `
|
||||
--labels $Labels
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Register failed. Check token + Gitea URL."
|
||||
exit 1
|
||||
}
|
||||
Write-Host " [OK] Registered as $RunnerName" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " [OK] Already registered (.runner exists)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- CONFIG -----------------------------------------------------------------
|
||||
Write-Host "3/4 — Generate config.yaml" -ForegroundColor Cyan
|
||||
$cfgFile = "$RunnerDir\config.yaml"
|
||||
if (-not (Test-Path $cfgFile)) {
|
||||
& $runnerExe generate-config | Set-Content $cfgFile -Encoding UTF8
|
||||
Write-Host " [OK] config.yaml ready — edit nếu cần tùy chỉnh" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- SERVICE ----------------------------------------------------------------
|
||||
Write-Host "4/4 — Install NSSM service 'act_runner'" -ForegroundColor Cyan
|
||||
$svc = Get-Service -Name 'act_runner' -ErrorAction SilentlyContinue
|
||||
if (-not $svc) {
|
||||
& $NSSM install act_runner $runnerExe daemon --config "$cfgFile"
|
||||
& $NSSM set act_runner AppDirectory $RunnerDir
|
||||
& $NSSM set act_runner AppStdout "$RunnerDir\runner-stdout.log"
|
||||
& $NSSM set act_runner AppStderr "$RunnerDir\runner-stderr.log"
|
||||
& $NSSM set act_runner AppRotateFiles 1
|
||||
& $NSSM set act_runner AppRotateBytes 10485760
|
||||
& $NSSM set act_runner DisplayName 'Gitea Actions Runner (DYD)'
|
||||
& $NSSM set act_runner Start SERVICE_AUTO_START
|
||||
Write-Host " [OK] Service installed" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Start-Service act_runner
|
||||
Start-Sleep 3
|
||||
$svc = Get-Service act_runner
|
||||
Write-Host " Service status: $($svc.Status)" -ForegroundColor $(if ($svc.Status -eq 'Running') {'Green'} else {'Red'})
|
||||
|
||||
# --- DONE -------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "=========================================================" -ForegroundColor Green
|
||||
Write-Host " act_runner ready — Gitea Actions sẵn sàng" -ForegroundColor Green
|
||||
Write-Host "=========================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Verify:"
|
||||
Write-Host " Gitea UI → Site Admin → Actions → Runner — phải thấy '$RunnerName' online"
|
||||
Write-Host ""
|
||||
Write-Host "Labels: $Labels"
|
||||
Write-Host "Logs: $RunnerDir\runner-*.log"
|
||||
@@ -0,0 +1,114 @@
|
||||
# ============================================================================
|
||||
# 06 — Setup Let's Encrypt SSL cho 3 domain qua win-acme
|
||||
# PREREQUISITE:
|
||||
# - DNS đã trỏ 3 domain về IP VPS (103.124.94.58)
|
||||
# - IIS sites đã bind với host header đúng
|
||||
# - Port 80 mở từ internet (cho HTTP-01 challenge)
|
||||
# ============================================================================
|
||||
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$wacs = 'C:\Tools\win-acme\wacs.exe'
|
||||
$email = 'admin@ski-ump.com.vn' # TODO: đổi email contact
|
||||
|
||||
if (-not (Test-Path $wacs)) {
|
||||
Write-Error "win-acme chưa cài. Chạy 01-install-prerequisites.ps1 trước."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- CHECK DNS ---------------------------------------------------------------
|
||||
$domains = @('api.ski-ump.com.vn', 'ski-ump.com.vn', 'admin.ski-ump.com.vn')
|
||||
$vpsIp = '103.124.94.58'
|
||||
|
||||
Write-Host "Checking DNS resolution (phải trỏ về $vpsIp) ..." -ForegroundColor Cyan
|
||||
$allOk = $true
|
||||
foreach ($d in $domains) {
|
||||
try {
|
||||
$resolved = (Resolve-DnsName $d -Type A -ErrorAction Stop | Where-Object { $_.Type -eq 'A' } | Select-Object -First 1).IPAddress
|
||||
if ($resolved -eq $vpsIp) {
|
||||
Write-Host " [OK] $d -> $resolved" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " [FAIL] $d -> $resolved (expected $vpsIp)" -ForegroundColor Red
|
||||
$allOk = $false
|
||||
}
|
||||
} catch {
|
||||
Write-Host " [FAIL] $d -> DNS not resolvable" -ForegroundColor Red
|
||||
$allOk = $false
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $allOk) {
|
||||
Write-Host ""
|
||||
Write-Host "DNS chưa sẵn sàng. Cần cấu hình A record cho 3 domain về $vpsIp trước." -ForegroundColor Red
|
||||
Write-Host "Vào DNS provider (Cloudflare/GoDaddy/...) tạo:" -ForegroundColor Yellow
|
||||
Write-Host " api.ski-ump.com.vn A $vpsIp"
|
||||
Write-Host " ski-ump.com.vn A $vpsIp"
|
||||
Write-Host " admin.ski-ump.com.vn A $vpsIp"
|
||||
Write-Host " (plus) www.ski-ump.com.vn CNAME ski-ump.com.vn"
|
||||
Write-Host ""
|
||||
Write-Host "Đợi ~15 phút cho DNS propagation rồi chạy lại script này."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- ISSUE CERTIFICATE -------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "Issuing Let's Encrypt cert cho 3 domain..." -ForegroundColor Cyan
|
||||
|
||||
$sites = @(
|
||||
@{ Domain = 'api.ski-ump.com.vn'; IISSite = 'DYD.Api' },
|
||||
@{ Domain = 'ski-ump.com.vn'; IISSite = 'DYD.User' },
|
||||
@{ Domain = 'admin.ski-ump.com.vn'; IISSite = 'DYD.Admin' }
|
||||
)
|
||||
|
||||
foreach ($s in $sites) {
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " SSL for $($s.Domain) -> $($s.IISSite)" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
|
||||
# Non-interactive mode
|
||||
& $wacs `
|
||||
--target manual `
|
||||
--host $s.Domain `
|
||||
--installation iis `
|
||||
--installationsiteid (Get-Website -Name $s.IISSite).Id `
|
||||
--emailaddress $email `
|
||||
--accepttos
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " [FAIL] Issue cert for $($s.Domain) (exit $LASTEXITCODE)" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host " [OK] Cert issued and bound to $($s.IISSite)" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# --- FORCE HTTPS REDIRECT ----------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "Adding HTTP→HTTPS redirect on all 3 sites..." -ForegroundColor Cyan
|
||||
|
||||
# Trên mỗi site, URL Rewrite rule để 301 redirect HTTP → HTTPS
|
||||
# Có thể làm ở web.config nhưng cho API phải cẩn thận (stdout log)
|
||||
# Simplest: enable HTTPS only binding, remove HTTP binding
|
||||
foreach ($s in $sites) {
|
||||
# Không remove HTTP binding vì Let's Encrypt cần port 80 để renew
|
||||
# Dùng URL Rewrite trong web.config hoặc HSTS
|
||||
Write-Host " $($s.IISSite): HTTPS cert đã bound. (HTTP binding để Let's Encrypt renew)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# --- VERIFY ------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "Verify bindings:" -ForegroundColor Cyan
|
||||
Get-WebBinding | Where-Object { $_.bindingInformation -match 'dyd' } | Format-Table protocol, bindingInformation -AutoSize
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=========================================================" -ForegroundColor Green
|
||||
Write-Host " SSL setup done" -ForegroundColor Green
|
||||
Write-Host "=========================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Test:"
|
||||
foreach ($s in $sites) {
|
||||
Write-Host " curl -I https://$($s.Domain)"
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Auto-renewal: win-acme tạo Scheduled Task tự động (daily check, renew 30 ngày trước expiry)."
|
||||
@@ -0,0 +1,52 @@
|
||||
# Hướng dẫn mở SSH trên VPS (1 lần duy nhất)
|
||||
|
||||
## Cách dễ nhất — copy script vào RDP
|
||||
|
||||
1. **RDP vào VPS:**
|
||||
- Windows + R → `mstsc`
|
||||
- Computer: `103.124.94.58:3389`
|
||||
- User: `Administrator`
|
||||
- Password: (dùng password đã cung cấp)
|
||||
|
||||
2. **Mở PowerShell AS ADMINISTRATOR:**
|
||||
- Start → gõ "PowerShell" → right-click → **Run as Administrator**
|
||||
|
||||
3. **Copy-paste toàn bộ file `scripts/deployment/00-enable-ssh-server.ps1`** vào PowerShell → Enter.
|
||||
|
||||
4. **Đợi ~1 phút**, khi thấy `DONE — SSH server ready` là xong.
|
||||
|
||||
5. **Báo lại cho dev**, dev sẽ test connection:
|
||||
```bash
|
||||
ssh -i ~/.ssh/dyd_vps Administrator@103.124.94.58 hostname
|
||||
```
|
||||
|
||||
## Nếu copy-paste qua RDP clipboard không được
|
||||
|
||||
**Option A — Upload file qua RDP mapped drive:**
|
||||
1. Trong mstsc → More → Local Resources → Drives → check `D:\` (hoặc drive chứa repo)
|
||||
2. Sau khi RDP, mở File Explorer → `\\tsclient\D\...\scripts\deployment\00-enable-ssh-server.ps1`
|
||||
3. Right-click file → Run with PowerShell (As Admin)
|
||||
|
||||
**Option B — Tải từ Gitea/GitHub (sau khi push):**
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri '<raw-url>/scripts/deployment/00-enable-ssh-server.ps1' -OutFile C:\Temp\00-enable-ssh.ps1
|
||||
powershell -ExecutionPolicy Bypass -File C:\Temp\00-enable-ssh.ps1
|
||||
```
|
||||
|
||||
## Script làm gì?
|
||||
|
||||
1. Install OpenSSH Server capability (Windows feature)
|
||||
2. Start `sshd` service + set auto-start
|
||||
3. Open firewall port 22
|
||||
4. Set default SSH shell = PowerShell (chứ không phải cmd)
|
||||
5. Add public key `dyd-vps-deploy-20260415` vào `administrators_authorized_keys`
|
||||
6. Fix file permission (chỉ Admin + SYSTEM được đọc)
|
||||
|
||||
**Idempotent** — chạy nhiều lần OK, không side effect.
|
||||
|
||||
## Security
|
||||
|
||||
- Public key đã embed trong script
|
||||
- Private key nằm trên máy dev (`~/.ssh/dyd_vps`) — KHÔNG rời máy
|
||||
- Nếu mất private key → chạy lại script sau khi đổi `$PublicKey` với key mới
|
||||
- Disable SSH sau khi xong deploy: `Stop-Service sshd; Set-Service sshd -StartupType Disabled`
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
web.config cho DYD.Api — ASP.NET Core 10 out-of-process / in-process hosting
|
||||
Đặt ở: C:\inetpub\DYD.Api\web.config
|
||||
Robocopy /XF web.config để không bị overwrite khi deploy.
|
||||
-->
|
||||
<configuration>
|
||||
<location path="." inheritInChildApplications="false">
|
||||
<system.webServer>
|
||||
<handlers>
|
||||
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
|
||||
</handlers>
|
||||
<aspNetCore processPath="dotnet"
|
||||
arguments=".\DYD.Api.dll"
|
||||
stdoutLogEnabled="true"
|
||||
stdoutLogFile=".\logs\stdout"
|
||||
hostingModel="InProcess"
|
||||
forwardWindowsAuthToken="false">
|
||||
<environmentVariables>
|
||||
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
|
||||
<!-- Nếu dùng User Secrets / env var cho JWT key, set ở đây hoặc qua IIS Configuration Editor -->
|
||||
<!--
|
||||
<environmentVariable name="ConnectionStrings__DefaultConnection" value="Server=..." />
|
||||
<environmentVariable name="Jwt__SigningKey" value="..." />
|
||||
-->
|
||||
</environmentVariables>
|
||||
</aspNetCore>
|
||||
|
||||
<!-- Security headers -->
|
||||
<httpProtocol>
|
||||
<customHeaders>
|
||||
<add name="X-Content-Type-Options" value="nosniff" />
|
||||
<add name="X-Frame-Options" value="DENY" />
|
||||
<add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
|
||||
<add name="Permissions-Policy" value="geolocation=(), microphone=(), camera=()" />
|
||||
</customHeaders>
|
||||
</httpProtocol>
|
||||
|
||||
<!-- Gzip compression -->
|
||||
<httpCompression>
|
||||
<dynamicTypes>
|
||||
<add mimeType="application/json" enabled="true" />
|
||||
<add mimeType="application/json; charset=utf-8" enabled="true" />
|
||||
</dynamicTypes>
|
||||
</httpCompression>
|
||||
|
||||
<!-- Larger file upload (cho InitiativeAttachments) -->
|
||||
<security>
|
||||
<requestFiltering>
|
||||
<!-- 50MB = 52428800 bytes -->
|
||||
<requestLimits maxAllowedContentLength="52428800" />
|
||||
</requestFiltering>
|
||||
</security>
|
||||
</system.webServer>
|
||||
</location>
|
||||
</configuration>
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
web.config cho React SPA (fe0 / fe-admin)
|
||||
Đặt ở: C:\inetpub\DYD.User\web.config và C:\inetpub\DYD.Admin\web.config
|
||||
|
||||
Mục đích:
|
||||
- URL Rewrite: React Router HTML5 history API → fallback /index.html
|
||||
- Cache static assets 1 năm (hash filename đảm bảo bust cache khi build mới)
|
||||
- Security headers
|
||||
- MIME types cho font/webmanifest
|
||||
|
||||
Deploy: Robocopy /XF web.config để không bị overwrite.
|
||||
-->
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
<!-- React Router rewrite -->
|
||||
<rewrite>
|
||||
<rules>
|
||||
<rule name="React Router SPA" stopProcessing="true">
|
||||
<match url=".*" />
|
||||
<conditions logicalGrouping="MatchAll">
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||
<!-- Không rewrite /api/* (nếu có reverse proxy) -->
|
||||
<add input="{REQUEST_URI}" pattern="^/(api)" negate="true" />
|
||||
</conditions>
|
||||
<action type="Rewrite" url="/index.html" />
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
|
||||
<!-- MIME types -->
|
||||
<staticContent>
|
||||
<remove fileExtension=".webmanifest" />
|
||||
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
|
||||
<remove fileExtension=".woff2" />
|
||||
<mimeMap fileExtension=".woff2" mimeType="font/woff2" />
|
||||
<!-- Cache static 1 năm (Vite build hash filename bust cache tự động) -->
|
||||
<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365.00:00:00" />
|
||||
</staticContent>
|
||||
|
||||
<!-- Security headers -->
|
||||
<httpProtocol>
|
||||
<customHeaders>
|
||||
<add name="X-Content-Type-Options" value="nosniff" />
|
||||
<add name="X-Frame-Options" value="DENY" />
|
||||
<add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
|
||||
<add name="Permissions-Policy" value="geolocation=(), microphone=(), camera=()" />
|
||||
<!-- CSP strict (điều chỉnh nếu cần CDN): -->
|
||||
<!-- <add name="Content-Security-Policy" value="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.ski-ump.com.vn" /> -->
|
||||
</customHeaders>
|
||||
</httpProtocol>
|
||||
|
||||
<!-- Gzip compression -->
|
||||
<httpCompression>
|
||||
<dynamicTypes>
|
||||
<add mimeType="text/*" enabled="true" />
|
||||
<add mimeType="application/javascript" enabled="true" />
|
||||
<add mimeType="application/json" enabled="true" />
|
||||
</dynamicTypes>
|
||||
<staticTypes>
|
||||
<add mimeType="text/*" enabled="true" />
|
||||
<add mimeType="application/javascript" enabled="true" />
|
||||
<add mimeType="application/json" enabled="true" />
|
||||
<add mimeType="application/manifest+json" enabled="true" />
|
||||
</staticTypes>
|
||||
</httpCompression>
|
||||
|
||||
<!-- Default document -->
|
||||
<defaultDocument>
|
||||
<files>
|
||||
<clear />
|
||||
<add value="index.html" />
|
||||
</files>
|
||||
</defaultDocument>
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
@@ -0,0 +1,56 @@
|
||||
# VPS inventory check
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
Write-Host "=== OS ===" -ForegroundColor Cyan
|
||||
$os = Get-WmiObject Win32_OperatingSystem
|
||||
$os | Select Caption, Version, OSArchitecture | Format-List
|
||||
"Memory: $([math]::Round($os.TotalVisibleMemorySize / 1MB, 2)) GB"
|
||||
|
||||
Write-Host "=== CPU ===" -ForegroundColor Cyan
|
||||
Get-WmiObject Win32_Processor | Select Name, NumberOfCores | Format-List
|
||||
|
||||
Write-Host "=== Disks ===" -ForegroundColor Cyan
|
||||
Get-PSDrive -PSProvider FileSystem | Where-Object Used -gt 0 | ForEach-Object {
|
||||
"Drive $($_.Name): Used $([math]::Round($_.Used / 1GB, 2)) GB, Free $([math]::Round($_.Free / 1GB, 2)) GB"
|
||||
}
|
||||
|
||||
Write-Host "=== IIS ===" -ForegroundColor Cyan
|
||||
$iis = Get-WindowsFeature Web-Server -ErrorAction SilentlyContinue
|
||||
if ($iis) { "IIS: $($iis.InstallState)" } else { "IIS: Not available via WindowsFeature" }
|
||||
$w3svc = Get-Service W3SVC -ErrorAction SilentlyContinue
|
||||
if ($w3svc) { "W3SVC service: $($w3svc.Status)" }
|
||||
|
||||
Write-Host "=== .NET SDKs ===" -ForegroundColor Cyan
|
||||
try { dotnet --list-sdks } catch { "dotnet not found" }
|
||||
|
||||
Write-Host "=== Node.js ===" -ForegroundColor Cyan
|
||||
try { node --version } catch { "node not found" }
|
||||
try { npm --version } catch { "npm not found" }
|
||||
|
||||
Write-Host "=== Git ===" -ForegroundColor Cyan
|
||||
try { git --version } catch { "git not found" }
|
||||
|
||||
Write-Host "=== SQL Server services ===" -ForegroundColor Cyan
|
||||
Get-Service MSSQL* -ErrorAction SilentlyContinue | Select Name, Status, StartType | Format-Table -AutoSize
|
||||
Get-Service SQL* -ErrorAction SilentlyContinue | Select Name, Status, StartType | Format-Table -AutoSize
|
||||
|
||||
Write-Host "=== PowerShell ===" -ForegroundColor Cyan
|
||||
$PSVersionTable.PSVersion
|
||||
|
||||
Write-Host "=== Listening ports ===" -ForegroundColor Cyan
|
||||
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LocalPort -in 22,80,443,1433,3000,3389,5443,5985,8080,8082 } |
|
||||
Select-Object LocalAddress, LocalPort |
|
||||
Sort-Object LocalPort |
|
||||
Get-Unique -AsString |
|
||||
Format-Table -AutoSize
|
||||
|
||||
Write-Host "=== ASP.NET Core Module ===" -ForegroundColor Cyan
|
||||
$hb = Test-Path 'HKLM:\SOFTWARE\Microsoft\ASP.NET Core\Hosting Bundle\v10.0'
|
||||
"ASP.NET Core 10 Hosting Bundle: $(if ($hb) {'Installed'} else {'NOT installed'})"
|
||||
$hb9 = Test-Path 'HKLM:\SOFTWARE\Microsoft\ASP.NET Core\Hosting Bundle\v9.0'
|
||||
"ASP.NET Core 9 Hosting Bundle: $(if ($hb9) {'Installed'} else {'NOT installed'})"
|
||||
|
||||
Write-Host "=== URL Rewrite ===" -ForegroundColor Cyan
|
||||
$ur = Test-Path 'HKLM:\SOFTWARE\Microsoft\IIS Extensions\URL Rewrite'
|
||||
"URL Rewrite: $(if ($ur) {'Installed'} else {'NOT installed'})"
|
||||
@@ -0,0 +1,78 @@
|
||||
-- ============================================================================
|
||||
-- DYD — Create Production Database + Dedicated Login
|
||||
-- Run on SQL Server as sysadmin (sa)
|
||||
-- ============================================================================
|
||||
|
||||
USE [master];
|
||||
GO
|
||||
|
||||
-- 1. Create database
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = N'DYD_Prod')
|
||||
BEGIN
|
||||
CREATE DATABASE [DYD_Prod]
|
||||
COLLATE Vietnamese_CI_AS;
|
||||
|
||||
-- Optional: set recovery model (Simple cho dev/staging, Full cho prod backup log)
|
||||
ALTER DATABASE [DYD_Prod] SET RECOVERY SIMPLE;
|
||||
PRINT 'Database [DYD_Prod] created.';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
PRINT 'Database [DYD_Prod] already exists. Skipping.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 2. Create dedicated login for the app (KHÔNG dùng sa trong production)
|
||||
-- TODO: Thay '<APP_DB_PASSWORD>' bằng password mạnh (32+ char, random)
|
||||
-- Generate: [System.Web.Security.Membership]::GeneratePassword(32, 8)
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = N'dyd_app')
|
||||
BEGIN
|
||||
CREATE LOGIN [dyd_app] WITH
|
||||
PASSWORD = N'<APP_DB_PASSWORD>',
|
||||
DEFAULT_DATABASE = [DYD_Prod],
|
||||
CHECK_EXPIRATION = OFF,
|
||||
CHECK_POLICY = ON;
|
||||
PRINT 'Login [dyd_app] created.';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
PRINT 'Login [dyd_app] already exists. Skipping.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 3. Map login to database user + assign roles
|
||||
USE [DYD_Prod];
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'dyd_app')
|
||||
BEGIN
|
||||
CREATE USER [dyd_app] FOR LOGIN [dyd_app];
|
||||
PRINT 'User [dyd_app] created in [DYD_Prod].';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Grant roles:
|
||||
-- db_datareader — SELECT
|
||||
-- db_datawriter — INSERT, UPDATE, DELETE
|
||||
-- db_ddladmin — CREATE/ALTER/DROP (cho EF migrations)
|
||||
ALTER ROLE db_datareader ADD MEMBER [dyd_app];
|
||||
ALTER ROLE db_datawriter ADD MEMBER [dyd_app];
|
||||
ALTER ROLE db_ddladmin ADD MEMBER [dyd_app];
|
||||
PRINT 'Roles granted to [dyd_app].';
|
||||
GO
|
||||
|
||||
-- 4. Verify
|
||||
SELECT
|
||||
DB_NAME() AS Database_Name,
|
||||
USER_NAME() AS Current_User,
|
||||
@@VERSION AS Server_Version;
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT '==========================================';
|
||||
PRINT ' DONE — DYD_Prod ready.';
|
||||
PRINT '==========================================';
|
||||
PRINT '';
|
||||
PRINT 'Connection string cho .NET (điền password đã tạo):';
|
||||
PRINT 'Server=103.124.94.58,1433;Database=DYD_Prod;User Id=dyd_app;Password=<APP_DB_PASSWORD>;TrustServerCertificate=True;MultipleActiveResultSets=True;';
|
||||
GO
|
||||
@@ -0,0 +1,653 @@
|
||||
IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE [__EFMigrationsHistory] (
|
||||
[MigrationId] nvarchar(150) NOT NULL,
|
||||
[ProductVersion] nvarchar(32) NOT NULL,
|
||||
CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId])
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [AppraisalTeams] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[Name] nvarchar(200) NOT NULL,
|
||||
[Description] nvarchar(max) NULL,
|
||||
[IsActive] bit NOT NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL,
|
||||
[DeletedAt] datetime2 NULL,
|
||||
[DeletedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_AppraisalTeams] PRIMARY KEY ([Id])
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [AspNetRoles] (
|
||||
[Id] nvarchar(450) NOT NULL,
|
||||
[Name] nvarchar(256) NULL,
|
||||
[NormalizedName] nvarchar(256) NULL,
|
||||
[ConcurrencyStamp] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_AspNetRoles] PRIMARY KEY ([Id])
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [AspNetUsers] (
|
||||
[Id] nvarchar(450) NOT NULL,
|
||||
[FullName] nvarchar(max) NOT NULL,
|
||||
[IsEnabled] bit NOT NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[LastLoginAt] datetime2 NULL,
|
||||
[RefreshToken] nvarchar(max) NULL,
|
||||
[RefreshTokenExpiresAt] datetime2 NULL,
|
||||
[UnitId] uniqueidentifier NULL,
|
||||
[UserName] nvarchar(256) NULL,
|
||||
[NormalizedUserName] nvarchar(256) NULL,
|
||||
[Email] nvarchar(256) NULL,
|
||||
[NormalizedEmail] nvarchar(256) NULL,
|
||||
[EmailConfirmed] bit NOT NULL,
|
||||
[PasswordHash] nvarchar(max) NULL,
|
||||
[SecurityStamp] nvarchar(max) NULL,
|
||||
[ConcurrencyStamp] nvarchar(max) NULL,
|
||||
[PhoneNumber] nvarchar(max) NULL,
|
||||
[PhoneNumberConfirmed] bit NOT NULL,
|
||||
[TwoFactorEnabled] bit NOT NULL,
|
||||
[LockoutEnd] datetimeoffset NULL,
|
||||
[LockoutEnabled] bit NOT NULL,
|
||||
[AccessFailedCount] int NOT NULL,
|
||||
CONSTRAINT [PK_AspNetUsers] PRIMARY KEY ([Id])
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [AuditLogs] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[EntityName] nvarchar(100) NOT NULL,
|
||||
[EntityId] nvarchar(100) NOT NULL,
|
||||
[Action] nvarchar(50) NOT NULL,
|
||||
[ActorUserId] nvarchar(max) NULL,
|
||||
[ActorDisplayName] nvarchar(max) NULL,
|
||||
[PreviousValue] nvarchar(max) NULL,
|
||||
[NewValue] nvarchar(max) NULL,
|
||||
[Metadata] nvarchar(max) NULL,
|
||||
[IpAddress] nvarchar(max) NULL,
|
||||
[UserAgent] nvarchar(max) NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_AuditLogs] PRIMARY KEY ([Id])
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [Notifications] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[RecipientUserId] nvarchar(450) NOT NULL,
|
||||
[Title] nvarchar(200) NOT NULL,
|
||||
[Message] nvarchar(max) NOT NULL,
|
||||
[Type] nvarchar(20) NOT NULL,
|
||||
[Link] nvarchar(max) NULL,
|
||||
[IsRead] bit NOT NULL,
|
||||
[ReadAt] datetime2 NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_Notifications] PRIMARY KEY ([Id])
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [SystemSettings] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[Key] nvarchar(100) NOT NULL,
|
||||
[Value] nvarchar(max) NOT NULL,
|
||||
[Description] nvarchar(max) NULL,
|
||||
[Category] nvarchar(50) NOT NULL,
|
||||
[IsSecret] bit NOT NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL,
|
||||
[DeletedAt] datetime2 NULL,
|
||||
[DeletedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_SystemSettings] PRIMARY KEY ([Id])
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [Units] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[Name] nvarchar(200) NOT NULL,
|
||||
[Code] nvarchar(50) NOT NULL,
|
||||
[Description] nvarchar(max) NULL,
|
||||
[ParentUnitId] uniqueidentifier NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL,
|
||||
[DeletedAt] datetime2 NULL,
|
||||
[DeletedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_Units] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_Units_Units_ParentUnitId] FOREIGN KEY ([ParentUnitId]) REFERENCES [Units] ([Id]) ON DELETE NO ACTION
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [AppraisalTeamMembers] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[AppraisalTeamId] uniqueidentifier NOT NULL,
|
||||
[UserId] nvarchar(450) NOT NULL,
|
||||
[Role] nvarchar(50) NOT NULL,
|
||||
[IsChair] bit NOT NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_AppraisalTeamMembers] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_AppraisalTeamMembers_AppraisalTeams_AppraisalTeamId] FOREIGN KEY ([AppraisalTeamId]) REFERENCES [AppraisalTeams] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [AspNetRoleClaims] (
|
||||
[Id] int NOT NULL IDENTITY,
|
||||
[RoleId] nvarchar(450) NOT NULL,
|
||||
[ClaimType] nvarchar(max) NULL,
|
||||
[ClaimValue] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_AspNetRoleClaims] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_AspNetRoleClaims_AspNetRoles_RoleId] FOREIGN KEY ([RoleId]) REFERENCES [AspNetRoles] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [AspNetUserClaims] (
|
||||
[Id] int NOT NULL IDENTITY,
|
||||
[UserId] nvarchar(450) NOT NULL,
|
||||
[ClaimType] nvarchar(max) NULL,
|
||||
[ClaimValue] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_AspNetUserClaims] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_AspNetUserClaims_AspNetUsers_UserId] FOREIGN KEY ([UserId]) REFERENCES [AspNetUsers] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [AspNetUserLogins] (
|
||||
[LoginProvider] nvarchar(450) NOT NULL,
|
||||
[ProviderKey] nvarchar(450) NOT NULL,
|
||||
[ProviderDisplayName] nvarchar(max) NULL,
|
||||
[UserId] nvarchar(450) NOT NULL,
|
||||
CONSTRAINT [PK_AspNetUserLogins] PRIMARY KEY ([LoginProvider], [ProviderKey]),
|
||||
CONSTRAINT [FK_AspNetUserLogins_AspNetUsers_UserId] FOREIGN KEY ([UserId]) REFERENCES [AspNetUsers] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [AspNetUserRoles] (
|
||||
[UserId] nvarchar(450) NOT NULL,
|
||||
[RoleId] nvarchar(450) NOT NULL,
|
||||
CONSTRAINT [PK_AspNetUserRoles] PRIMARY KEY ([UserId], [RoleId]),
|
||||
CONSTRAINT [FK_AspNetUserRoles_AspNetRoles_RoleId] FOREIGN KEY ([RoleId]) REFERENCES [AspNetRoles] ([Id]) ON DELETE CASCADE,
|
||||
CONSTRAINT [FK_AspNetUserRoles_AspNetUsers_UserId] FOREIGN KEY ([UserId]) REFERENCES [AspNetUsers] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [AspNetUserTokens] (
|
||||
[UserId] nvarchar(450) NOT NULL,
|
||||
[LoginProvider] nvarchar(450) NOT NULL,
|
||||
[Name] nvarchar(450) NOT NULL,
|
||||
[Value] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_AspNetUserTokens] PRIMARY KEY ([UserId], [LoginProvider], [Name]),
|
||||
CONSTRAINT [FK_AspNetUserTokens_AspNetUsers_UserId] FOREIGN KEY ([UserId]) REFERENCES [AspNetUsers] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [Authors] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[FullName] nvarchar(200) NOT NULL,
|
||||
[Email] nvarchar(200) NOT NULL,
|
||||
[PhoneNumber] nvarchar(max) NULL,
|
||||
[Position] nvarchar(max) NULL,
|
||||
[AcademicTitle] nvarchar(max) NULL,
|
||||
[UnitId] uniqueidentifier NULL,
|
||||
[UserId] nvarchar(max) NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL,
|
||||
[DeletedAt] datetime2 NULL,
|
||||
[DeletedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_Authors] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_Authors_Units_UnitId] FOREIGN KEY ([UnitId]) REFERENCES [Units] ([Id]) ON DELETE SET NULL
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [Initiatives] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[Code] nvarchar(50) NOT NULL,
|
||||
[Title] nvarchar(500) NOT NULL,
|
||||
[Description] nvarchar(max) NOT NULL,
|
||||
[ShortSummary] nvarchar(max) NULL,
|
||||
[Objectives] nvarchar(max) NULL,
|
||||
[ScopeOfApplication] nvarchar(max) NULL,
|
||||
[ExpectedOutcomes] nvarchar(max) NULL,
|
||||
[ActualOutcomes] nvarchar(max) NULL,
|
||||
[EstimatedBudget] decimal(18,2) NULL,
|
||||
[ActualBudget] decimal(18,2) NULL,
|
||||
[StartDate] datetime2 NULL,
|
||||
[EndDate] datetime2 NULL,
|
||||
[SubmissionDate] datetime2 NULL,
|
||||
[ApprovalDate] datetime2 NULL,
|
||||
[Status] int NOT NULL,
|
||||
[Category] int NOT NULL,
|
||||
[Group] int NOT NULL,
|
||||
[OwningUnitId] uniqueidentifier NOT NULL,
|
||||
[SubmittedByUserId] nvarchar(max) NOT NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL,
|
||||
[DeletedAt] datetime2 NULL,
|
||||
[DeletedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_Initiatives] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_Initiatives_Units_OwningUnitId] FOREIGN KEY ([OwningUnitId]) REFERENCES [Units] ([Id]) ON DELETE NO ACTION
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [InitiativeAttachments] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[InitiativeId] uniqueidentifier NOT NULL,
|
||||
[FileName] nvarchar(260) NOT NULL,
|
||||
[StoragePath] nvarchar(500) NOT NULL,
|
||||
[ContentType] nvarchar(100) NOT NULL,
|
||||
[FileSize] bigint NOT NULL,
|
||||
[Description] nvarchar(max) NULL,
|
||||
[Category] nvarchar(max) NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL,
|
||||
[DeletedAt] datetime2 NULL,
|
||||
[DeletedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_InitiativeAttachments] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_InitiativeAttachments_Initiatives_InitiativeId] FOREIGN KEY ([InitiativeId]) REFERENCES [Initiatives] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [InitiativeAuthors] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[InitiativeId] uniqueidentifier NOT NULL,
|
||||
[AuthorId] uniqueidentifier NOT NULL,
|
||||
[ContributionPercentage] decimal(5,2) NOT NULL,
|
||||
[IsLeadAuthor] bit NOT NULL,
|
||||
[ContributionDescription] nvarchar(max) NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_InitiativeAuthors] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_InitiativeAuthors_Authors_AuthorId] FOREIGN KEY ([AuthorId]) REFERENCES [Authors] ([Id]) ON DELETE NO ACTION,
|
||||
CONSTRAINT [FK_InitiativeAuthors_Initiatives_InitiativeId] FOREIGN KEY ([InitiativeId]) REFERENCES [Initiatives] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [InitiativeStatusHistories] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[InitiativeId] uniqueidentifier NOT NULL,
|
||||
[FromStatus] int NOT NULL,
|
||||
[ToStatus] int NOT NULL,
|
||||
[Comment] nvarchar(max) NULL,
|
||||
[ChangedByUserId] nvarchar(max) NOT NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_InitiativeStatusHistories] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_InitiativeStatusHistories_Initiatives_InitiativeId] FOREIGN KEY ([InitiativeId]) REFERENCES [Initiatives] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [Reviews] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[InitiativeId] uniqueidentifier NOT NULL,
|
||||
[ReviewerUserId] nvarchar(max) NOT NULL,
|
||||
[AppraisalTeamId] uniqueidentifier NOT NULL,
|
||||
[Score] decimal(5,2) NULL,
|
||||
[NoveltyScore] decimal(5,2) NULL,
|
||||
[FeasibilityScore] decimal(5,2) NULL,
|
||||
[ImpactScore] decimal(5,2) NULL,
|
||||
[EfficiencyScore] decimal(5,2) NULL,
|
||||
[Decision] int NOT NULL,
|
||||
[Comments] nvarchar(max) NULL,
|
||||
[Strengths] nvarchar(max) NULL,
|
||||
[Weaknesses] nvarchar(max) NULL,
|
||||
[Recommendations] nvarchar(max) NULL,
|
||||
[ReviewedAt] datetime2 NULL,
|
||||
[DueDate] datetime2 NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL,
|
||||
[DeletedAt] datetime2 NULL,
|
||||
[DeletedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_Reviews] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_Reviews_AppraisalTeams_AppraisalTeamId] FOREIGN KEY ([AppraisalTeamId]) REFERENCES [AppraisalTeams] ([Id]) ON DELETE NO ACTION,
|
||||
CONSTRAINT [FK_Reviews_Initiatives_InitiativeId] FOREIGN KEY ([InitiativeId]) REFERENCES [Initiatives] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX [IX_AppraisalTeamMembers_AppraisalTeamId_UserId] ON [AppraisalTeamMembers] ([AppraisalTeamId], [UserId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_AspNetRoleClaims_RoleId] ON [AspNetRoleClaims] ([RoleId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
EXEC(N'CREATE UNIQUE INDEX [RoleNameIndex] ON [AspNetRoles] ([NormalizedName]) WHERE [NormalizedName] IS NOT NULL');
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_AspNetUserClaims_UserId] ON [AspNetUserClaims] ([UserId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_AspNetUserLogins_UserId] ON [AspNetUserLogins] ([UserId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_AspNetUserRoles_RoleId] ON [AspNetUserRoles] ([RoleId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [EmailIndex] ON [AspNetUsers] ([NormalizedEmail]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
EXEC(N'CREATE UNIQUE INDEX [UserNameIndex] ON [AspNetUsers] ([NormalizedUserName]) WHERE [NormalizedUserName] IS NOT NULL');
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_AuditLogs_CreatedAt] ON [AuditLogs] ([CreatedAt]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_AuditLogs_EntityName_EntityId] ON [AuditLogs] ([EntityName], [EntityId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_Authors_Email] ON [Authors] ([Email]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_Authors_UnitId] ON [Authors] ([UnitId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_InitiativeAttachments_InitiativeId] ON [InitiativeAttachments] ([InitiativeId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_InitiativeAuthors_AuthorId] ON [InitiativeAuthors] ([AuthorId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX [IX_InitiativeAuthors_InitiativeId_AuthorId] ON [InitiativeAuthors] ([InitiativeId], [AuthorId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_Initiatives_Category] ON [Initiatives] ([Category]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX [IX_Initiatives_Code] ON [Initiatives] ([Code]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_Initiatives_OwningUnitId] ON [Initiatives] ([OwningUnitId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_Initiatives_Status] ON [Initiatives] ([Status]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_InitiativeStatusHistories_InitiativeId] ON [InitiativeStatusHistories] ([InitiativeId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_Notifications_RecipientUserId_IsRead] ON [Notifications] ([RecipientUserId], [IsRead]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_Reviews_AppraisalTeamId] ON [Reviews] ([AppraisalTeamId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_Reviews_InitiativeId] ON [Reviews] ([InitiativeId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX [IX_SystemSettings_Key] ON [SystemSettings] ([Key]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX [IX_Units_Code] ON [Units] ([Code]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_Units_ParentUnitId] ON [Units] ([ParentUnitId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260415141734_InitialCreate'
|
||||
)
|
||||
BEGIN
|
||||
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
|
||||
VALUES (N'20260415141734_InitialCreate', N'10.0.6');
|
||||
END;
|
||||
|
||||
COMMIT;
|
||||
GO
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
BEGIN TRANSACTION;
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [InitiativeReports] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[Code] nvarchar(50) NOT NULL,
|
||||
[InitiativeId] uniqueidentifier NOT NULL,
|
||||
[ActualOutcomes] nvarchar(4000) NULL,
|
||||
[ActualBudget] decimal(18,2) NULL,
|
||||
[ImplementationNotes] nvarchar(4000) NULL,
|
||||
[Challenges] nvarchar(4000) NULL,
|
||||
[LessonsLearned] nvarchar(4000) NULL,
|
||||
[Status] int NOT NULL,
|
||||
[SubmissionDate] datetime2 NULL,
|
||||
[ApprovalDate] datetime2 NULL,
|
||||
[SubmittedByUserId] nvarchar(450) NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL,
|
||||
[DeletedAt] datetime2 NULL,
|
||||
[DeletedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_InitiativeReports] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_InitiativeReports_Initiatives_InitiativeId] FOREIGN KEY ([InitiativeId]) REFERENCES [Initiatives] ([Id]) ON DELETE NO ACTION
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [RecognitionDocuments] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[Code] nvarchar(50) NOT NULL,
|
||||
[ReportId] uniqueidentifier NOT NULL,
|
||||
[Type] int NOT NULL,
|
||||
[Content] nvarchar(max) NULL,
|
||||
[Summary] nvarchar(1000) NULL,
|
||||
[Status] int NOT NULL,
|
||||
[SubmissionDate] datetime2 NULL,
|
||||
[ApprovalDate] datetime2 NULL,
|
||||
[SubmittedByUserId] nvarchar(450) NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL,
|
||||
[DeletedAt] datetime2 NULL,
|
||||
[DeletedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_RecognitionDocuments] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_RecognitionDocuments_InitiativeReports_ReportId] FOREIGN KEY ([ReportId]) REFERENCES [InitiativeReports] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [ReportStatusHistories] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[ReportId] uniqueidentifier NOT NULL,
|
||||
[FromStatus] int NOT NULL,
|
||||
[ToStatus] int NOT NULL,
|
||||
[Comment] nvarchar(2000) NULL,
|
||||
[ChangedByUserId] nvarchar(450) NOT NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL,
|
||||
[DeletedAt] datetime2 NULL,
|
||||
[DeletedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_ReportStatusHistories] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_ReportStatusHistories_InitiativeReports_ReportId] FOREIGN KEY ([ReportId]) REFERENCES [InitiativeReports] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
|
||||
)
|
||||
BEGIN
|
||||
CREATE TABLE [DocumentStatusHistories] (
|
||||
[Id] uniqueidentifier NOT NULL,
|
||||
[DocumentId] uniqueidentifier NOT NULL,
|
||||
[FromStatus] int NOT NULL,
|
||||
[ToStatus] int NOT NULL,
|
||||
[Comment] nvarchar(2000) NULL,
|
||||
[ChangedByUserId] nvarchar(450) NOT NULL,
|
||||
[CreatedAt] datetime2 NOT NULL,
|
||||
[UpdatedAt] datetime2 NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[UpdatedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL,
|
||||
[DeletedAt] datetime2 NULL,
|
||||
[DeletedBy] nvarchar(max) NULL,
|
||||
CONSTRAINT [PK_DocumentStatusHistories] PRIMARY KEY ([Id]),
|
||||
CONSTRAINT [FK_DocumentStatusHistories_RecognitionDocuments_DocumentId] FOREIGN KEY ([DocumentId]) REFERENCES [RecognitionDocuments] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_DocumentStatusHistories_DocumentId] ON [DocumentStatusHistories] ([DocumentId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
|
||||
)
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX [IX_InitiativeReports_Code] ON [InitiativeReports] ([Code]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_InitiativeReports_InitiativeId] ON [InitiativeReports] ([InitiativeId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_InitiativeReports_Status] ON [InitiativeReports] ([Status]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
|
||||
)
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX [IX_RecognitionDocuments_Code] ON [RecognitionDocuments] ([Code]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
|
||||
)
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX [IX_RecognitionDocuments_ReportId_Type] ON [RecognitionDocuments] ([ReportId], [Type]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX [IX_ReportStatusHistories_ReportId] ON [ReportStatusHistories] ([ReportId]);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260416081839_AddReportAndDocumentEntities'
|
||||
)
|
||||
BEGIN
|
||||
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
|
||||
VALUES (N'20260416081839_AddReportAndDocumentEntities', N'10.0.6');
|
||||
END;
|
||||
|
||||
COMMIT;
|
||||
GO
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
where pwsh >nul 2>&1
|
||||
if %ERRORLEVEL% == 0 (
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0health-check.ps1" %*
|
||||
) else (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0health-check.ps1" %*
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
# Health check for all running services
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$endpoints = @(
|
||||
@{ Name = '.NET API (DYD.Api)'; Url = 'http://localhost:5443/health'; Critical = $true },
|
||||
@{ Name = 'Swagger UI'; Url = 'http://localhost:5443/swagger/v1/swagger.json'; Critical = $false },
|
||||
@{ Name = 'Python AI Service'; Url = 'http://localhost:4402/health'; Critical = $false },
|
||||
@{ Name = 'Qdrant vector DB'; Url = 'http://localhost:6333/healthz'; Critical = $false },
|
||||
@{ Name = 'fe0 (User UI)'; Url = 'http://localhost:8080'; Critical = $false },
|
||||
@{ Name = 'fe-admin (Admin UI)'; Url = 'http://localhost:8082'; Critical = $false },
|
||||
@{ Name = 'Ollama'; Url = 'http://localhost:11434/api/tags'; Critical = $false }
|
||||
)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Health Check - DYD" -ForegroundColor Cyan
|
||||
Write-Host ("-" * 70)
|
||||
|
||||
$allOk = $true
|
||||
foreach ($ep in $endpoints) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri $ep.Url -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop
|
||||
if ($r.StatusCode -lt 400) {
|
||||
$status = '[UP] '
|
||||
}
|
||||
else {
|
||||
$status = '[DEG]'
|
||||
}
|
||||
Write-Host ("{0} {1,-30} -> HTTP {2}" -f $status, $ep.Name, $r.StatusCode) -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
if ($ep.Critical) {
|
||||
$color = 'Red'
|
||||
$marker = '[DOWN]'
|
||||
}
|
||||
else {
|
||||
$color = 'DarkYellow'
|
||||
$marker = '[OFF] '
|
||||
}
|
||||
$msg = $_.Exception.Message
|
||||
Write-Host ("{0} {1,-30} -> {2}" -f $marker, $ep.Name, $msg) -ForegroundColor $color
|
||||
if ($ep.Critical) { $allOk = $false }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ("-" * 70)
|
||||
if ($allOk) {
|
||||
Write-Host "[OK] All critical services are running." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
else {
|
||||
Write-Host "[ERROR] Some critical services are down!" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Seed 10 medical initiatives across all workflow states.
|
||||
Each initiative represents a realistic medical/healthcare innovation.
|
||||
Covers: Draft, Submitted, UnitReview, CouncilReview, Approved (ready for report), Finalized.
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
|
||||
API = 'https://api.ski-ump.com.vn/api'
|
||||
ADMIN_EMAIL = 'admin@dhyd.local'
|
||||
ADMIN_PASSWORD = 'Admin@123456'
|
||||
|
||||
def login(email, password):
|
||||
r = requests.post(f'{API}/auth/login',
|
||||
json={'email': email, 'password': password},
|
||||
headers={'Content-Type': 'application/json; charset=utf-8'})
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
return data['accessToken'], data['userId']
|
||||
|
||||
def get_units(token):
|
||||
r = requests.get(f'{API}/admin/units', headers={'Authorization': f'Bearer {token}'})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def register_user(email, password, full_name, role, admin_token):
|
||||
"""Register + assign role"""
|
||||
# Register
|
||||
r = requests.post(f'{API}/auth/register',
|
||||
json={'email': email, 'password': password, 'fullName': full_name},
|
||||
headers={'Authorization': f'Bearer {admin_token}', 'Content-Type': 'application/json; charset=utf-8'})
|
||||
if r.status_code == 200 or r.status_code == 201 or r.status_code == 204:
|
||||
data = r.json() if r.text else {}
|
||||
user_id = data.get('userId') or data.get('id')
|
||||
if user_id and role != 'Viewer':
|
||||
# Assign role
|
||||
r2 = requests.post(f'{API}/admin/users/{user_id}/roles/{role}',
|
||||
headers={'Authorization': f'Bearer {admin_token}'})
|
||||
print(f' → Assigned role {role}: {r2.status_code}')
|
||||
print(f'[OK] Created user {email} ({role})')
|
||||
return user_id
|
||||
elif r.status_code == 400 and 'already' in r.text.lower():
|
||||
print(f'[SKIP] User {email} already exists')
|
||||
return None
|
||||
else:
|
||||
print(f'[FAIL] {email}: {r.status_code} {r.text[:200]}')
|
||||
return None
|
||||
|
||||
def create_initiative(token, data):
|
||||
r = requests.post(f'{API}/initiatives',
|
||||
json=data,
|
||||
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json; charset=utf-8'})
|
||||
if r.status_code not in (200, 201):
|
||||
print(f'[FAIL] Create initiative: {r.status_code} {r.text[:300]}')
|
||||
return None
|
||||
result = r.json()
|
||||
print(f'[OK] Created initiative: {result.get("code","?")} - {data["title"][:50]}')
|
||||
return result.get('id')
|
||||
|
||||
def change_status(token, initiative_id, target_status, comment):
|
||||
r = requests.post(f'{API}/initiatives/{initiative_id}/status',
|
||||
json={'targetStatus': target_status, 'comment': comment},
|
||||
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json; charset=utf-8'})
|
||||
if r.status_code not in (200, 204):
|
||||
print(f'[FAIL] Status change to {target_status}: {r.status_code} {r.text[:200]}')
|
||||
return False
|
||||
return True
|
||||
|
||||
def submit(token, initiative_id):
|
||||
r = requests.post(f'{API}/initiatives/{initiative_id}/submit',
|
||||
json='Nộp tự động từ seed script',
|
||||
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json; charset=utf-8'})
|
||||
return r.status_code in (200, 204)
|
||||
|
||||
def approve(token, initiative_id):
|
||||
r = requests.post(f'{API}/initiatives/{initiative_id}/approve',
|
||||
json='Phê duyệt từ seed script',
|
||||
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json; charset=utf-8'})
|
||||
return r.status_code in (200, 204)
|
||||
|
||||
def reject(token, initiative_id):
|
||||
r = requests.post(f'{API}/initiatives/{initiative_id}/reject',
|
||||
json='Từ chối test',
|
||||
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json; charset=utf-8'})
|
||||
return r.status_code in (200, 204)
|
||||
|
||||
# Medical initiatives sample data
|
||||
INITIATIVES = [
|
||||
{
|
||||
'title': 'Ứng dụng Trí tuệ nhân tạo trong chẩn đoán hình ảnh X-quang phổi',
|
||||
'shortSummary': 'Phát triển mô hình AI hỗ trợ bác sĩ chẩn đoán bệnh lý phổi qua ảnh X-quang',
|
||||
'description': 'Đề xuất xây dựng hệ thống AI dựa trên Deep Learning (CNN) để phân tích ảnh X-quang phổi, phát hiện sớm các bệnh lý như viêm phổi, lao, ung thư phổi. Hệ thống hỗ trợ bác sĩ giảm thời gian chẩn đoán và tăng độ chính xác lên 92%.',
|
||||
'objectives': 'Xây dựng mô hình AI đạt độ chính xác > 90% cho 5 bệnh lý phổi phổ biến. Triển khai thí điểm tại Bệnh viện Đại học Y Dược.',
|
||||
'scopeOfApplication': 'Khoa Chẩn đoán hình ảnh, Khoa Nội tổng hợp, Khoa Lao và bệnh phổi',
|
||||
'expectedOutcomes': 'Giảm 40% thời gian chẩn đoán. Tăng 15% độ chính xác phát hiện sớm. Công bố 2 bài báo khoa học quốc tế.',
|
||||
'category': 6, # Technology
|
||||
'group': 1,
|
||||
'unit_code': 'KY',
|
||||
'estimatedBudget': 500000000,
|
||||
'target_status': 3, # Xét cấp trường
|
||||
},
|
||||
{
|
||||
'title': 'Chương trình đào tạo lâm sàng tích hợp thực tại ảo (VR) cho sinh viên Y',
|
||||
'shortSummary': 'Ứng dụng công nghệ VR trong giảng dạy lâm sàng, mô phỏng ca bệnh và quy trình phẫu thuật',
|
||||
'description': 'Xây dựng chương trình đào tạo kết hợp VR giúp sinh viên thực hành các kỹ năng lâm sàng trong môi trường ảo an toàn, giảm áp lực lên bệnh viện thực hành, tăng khả năng xử lý tình huống khẩn cấp.',
|
||||
'objectives': 'Thiết lập 3 phòng lab VR. Xây dựng 20 ca bệnh mô phỏng. Triển khai cho 500 sinh viên Y khoa năm 3-6.',
|
||||
'scopeOfApplication': 'Khoa Y, sinh viên hệ chính quy và liên thông',
|
||||
'expectedOutcomes': 'Cải thiện 30% điểm thi lâm sàng. Giảm 50% lỗi kỹ thuật ở sinh viên mới ra trường.',
|
||||
'category': 1, # Education
|
||||
'group': 1,
|
||||
'unit_code': 'PDT',
|
||||
'estimatedBudget': 2000000000,
|
||||
'target_status': 4, # Đã duyệt
|
||||
},
|
||||
{
|
||||
'title': 'Phát triển quy trình bào chế thuốc kháng viêm từ dược liệu Việt Nam',
|
||||
'shortSummary': 'Nghiên cứu chiết xuất hoạt chất kháng viêm từ Nghệ vàng, Ngải cứu, Trà xanh',
|
||||
'description': 'Phát triển quy trình chuẩn hóa chiết xuất Curcumin, Artemisinin và Catechin theo GMP-WHO để sản xuất thuốc kháng viêm bản địa, thay thế các thuốc nhập khẩu.',
|
||||
'objectives': 'Xây dựng 3 quy trình chuẩn (curcumin >95%, artemisinin >98%, EGCG >40%). Đăng ký 2 bằng sáng chế.',
|
||||
'scopeOfApplication': 'Khoa Dược, Công ty Dược phẩm Đại học Y Dược',
|
||||
'expectedOutcomes': 'Sản xuất 10.000 đơn vị sản phẩm/năm. Giảm 60% chi phí so với thuốc nhập.',
|
||||
'category': 2, # Research
|
||||
'group': 2,
|
||||
'unit_code': 'KD',
|
||||
'estimatedBudget': 1500000000,
|
||||
'target_status': 7, # Finalized (đã hoàn tất)
|
||||
},
|
||||
{
|
||||
'title': 'Mô hình chăm sóc bệnh nhân cao tuổi tại nhà (Home Care) với ứng dụng IoT',
|
||||
'shortSummary': 'Hệ thống theo dõi sức khỏe người già tại nhà qua thiết bị IoT kết nối bác sĩ',
|
||||
'description': 'Triển khai mô hình chăm sóc tại nhà sử dụng vòng đeo tay, máy đo huyết áp, đường huyết thông minh kết nối với app điều dưỡng. Bác sĩ nhận cảnh báo theo dõi real-time.',
|
||||
'objectives': 'Phủ 500 hộ gia đình trong 2 năm. Giảm 30% lượt nhập viện cấp cứu ở người > 65 tuổi.',
|
||||
'scopeOfApplication': 'Khoa Điều Dưỡng, Trung tâm Y tế cộng đồng',
|
||||
'expectedOutcomes': 'Cứu sống 15-20 ca mỗi năm nhờ phát hiện sớm. Giảm 25% chi phí điều trị.',
|
||||
'category': 3, # Social Impact
|
||||
'group': 1,
|
||||
'unit_code': 'KDD',
|
||||
'estimatedBudget': 800000000,
|
||||
'target_status': 2, # Xét cấp khoa
|
||||
},
|
||||
{
|
||||
'title': 'Ứng dụng liệu pháp tế bào gốc trong điều trị viêm khớp mạn tính',
|
||||
'shortSummary': 'Nghiên cứu lâm sàng sử dụng tế bào gốc trung mô điều trị thoái hóa khớp',
|
||||
'description': 'Thử nghiệm lâm sàng pha II sử dụng tế bào gốc trung mô (MSC) tách từ mô mỡ tự thân để điều trị thoái hóa khớp gối độ 2-3, tránh phẫu thuật thay khớp nhân tạo.',
|
||||
'objectives': 'Đạt tỷ lệ cải thiện > 70% sau 6 tháng. Giảm điểm đau VAS từ 7-8 xuống 2-3.',
|
||||
'scopeOfApplication': 'Khoa Y, Khoa Cơ xương khớp, Bệnh viện ĐHYD',
|
||||
'expectedOutcomes': 'Công bố 3 bài báo. Ứng dụng rộng sau khi xong pha III.',
|
||||
'category': 2, # Research
|
||||
'group': 2,
|
||||
'unit_code': 'KY',
|
||||
'estimatedBudget': 3000000000,
|
||||
'target_status': 3, # Xét cấp trường
|
||||
},
|
||||
{
|
||||
'title': 'Hệ thống số hóa hồ sơ bệnh án nha khoa với chuẩn HL7 FHIR',
|
||||
'shortSummary': 'Xây dựng hồ sơ bệnh án nha khoa điện tử tích hợp chụp 3D CBCT và X-quang',
|
||||
'description': 'Chuyển đổi toàn bộ hồ sơ bệnh án giấy sang điện tử, tích hợp ảnh chụp 3D, theo chuẩn HL7 FHIR để trao đổi dữ liệu giữa các bệnh viện.',
|
||||
'objectives': 'Số hóa 100% hồ sơ trong 18 tháng. Trích xuất được insights từ dữ liệu.',
|
||||
'scopeOfApplication': 'Khoa Răng Hàm Mặt',
|
||||
'expectedOutcomes': 'Giảm 70% thời gian tra cứu. Tăng chất lượng chẩn đoán.',
|
||||
'category': 6, # Technology
|
||||
'group': 1,
|
||||
'unit_code': 'KRHM',
|
||||
'estimatedBudget': 1200000000,
|
||||
'target_status': 1, # Đã nộp
|
||||
},
|
||||
{
|
||||
'title': 'Nghiên cứu dịch tễ học bệnh đái tháo đường type 2 tại TP.HCM',
|
||||
'shortSummary': 'Khảo sát tỷ lệ và yếu tố nguy cơ ĐTĐ tại 10 quận huyện TP.HCM',
|
||||
'description': 'Nghiên cứu cắt ngang trên 5000 người từ 30-70 tuổi tại TP.HCM để xác định tỷ lệ mắc, yếu tố nguy cơ (di truyền, lối sống, béo phì), đề xuất chính sách y tế dự phòng.',
|
||||
'objectives': 'Có dữ liệu đại diện quần thể TP.HCM. Xây dựng mô hình dự đoán nguy cơ.',
|
||||
'scopeOfApplication': 'Khoa Y, Sở Y tế TP.HCM',
|
||||
'expectedOutcomes': 'Xuất bản 5 bài báo ISI. Đề xuất chương trình tầm soát quốc gia.',
|
||||
'category': 2, # Research
|
||||
'group': 2,
|
||||
'unit_code': 'KY',
|
||||
'estimatedBudget': 2500000000,
|
||||
'target_status': 0, # Nháp (chưa nộp)
|
||||
},
|
||||
{
|
||||
'title': 'Phương pháp châm cứu điện kết hợp vật lý trị liệu điều trị đau lưng mạn',
|
||||
'shortSummary': 'Kết hợp y học cổ truyền và hiện đại điều trị đau lưng mạn tính',
|
||||
'description': 'Phương pháp kết hợp châm cứu điện tại các huyệt Thận du, Đại trường du với bài tập kéo giãn và tăng cường cơ lưng. Nghiên cứu đối chứng ngẫu nhiên 200 bệnh nhân.',
|
||||
'objectives': 'Giảm ≥50% điểm đau sau 4 tuần. Cải thiện chất lượng cuộc sống (SF-36).',
|
||||
'scopeOfApplication': 'Khoa Y Học Cổ Truyền, Khoa Phục hồi chức năng',
|
||||
'expectedOutcomes': 'Xây dựng phác đồ chuẩn. Đào tạo 50 bác sĩ YHCT.',
|
||||
'category': 2, # Research
|
||||
'group': 1,
|
||||
'unit_code': 'KYHCT',
|
||||
'estimatedBudget': 600000000,
|
||||
'target_status': 4, # Đã duyệt
|
||||
},
|
||||
{
|
||||
'title': 'Cải tiến quy trình quản lý nghiên cứu khoa học cấp trường qua cổng điện tử',
|
||||
'shortSummary': 'Xây dựng cổng thông tin quản lý nghiên cứu khoa học từ đề xuất đến nghiệm thu',
|
||||
'description': 'Phát triển web app cho phép giảng viên nộp đề xuất, theo dõi tiến độ, báo cáo định kỳ, nghiệm thu online. Tích hợp AI kiểm tra trùng lặp đề tài.',
|
||||
'objectives': 'Số hóa 100% quy trình. Giảm 80% thời gian xử lý giấy tờ.',
|
||||
'scopeOfApplication': 'Phòng Khoa học Công nghệ, toàn trường',
|
||||
'expectedOutcomes': 'Tăng 30% số đề tài được phê duyệt. Tiết kiệm 2000 giờ nhân sự/năm.',
|
||||
'category': 5, # Management
|
||||
'group': 1,
|
||||
'unit_code': 'PKHCN',
|
||||
'estimatedBudget': 400000000,
|
||||
'target_status': 5, # Từ chối
|
||||
},
|
||||
{
|
||||
'title': 'Chương trình đánh giá năng lực giảng viên theo chuẩn quốc tế (AACSB)',
|
||||
'shortSummary': 'Xây dựng framework đánh giá giảng viên dựa trên chuẩn AACSB và ABET',
|
||||
'description': 'Thiết kế hệ thống đánh giá toàn diện năng lực giảng viên: giảng dạy, nghiên cứu, cộng đồng. Tích hợp phần mềm thu thập phản hồi sinh viên, đồng nghiệp.',
|
||||
'objectives': 'Đánh giá 100% giảng viên trong 3 năm. Công bố Chương trình đạt chuẩn quốc tế.',
|
||||
'scopeOfApplication': 'Phòng Tổ chức Cán bộ, toàn trường',
|
||||
'expectedOutcomes': 'Nâng cao chất lượng giảng viên. Đạt chứng nhận kiểm định ngành 2027.',
|
||||
'category': 5, # Management
|
||||
'group': 1,
|
||||
'unit_code': 'PTCCB',
|
||||
'estimatedBudget': 700000000,
|
||||
'target_status': 2, # Xét cấp khoa
|
||||
},
|
||||
]
|
||||
|
||||
def advance_to_status(token, initiative_id, target):
|
||||
"""Move initiative from Draft (0) step-by-step to target status"""
|
||||
# State machine: 0 -> 1 -> 2 -> 3 -> 4 ; or 0 -> 1 -> 5 (reject)
|
||||
if target == 0:
|
||||
return True # already Draft
|
||||
if not submit(token, initiative_id):
|
||||
print(f' [FAIL] submit')
|
||||
return False
|
||||
if target == 1:
|
||||
return True
|
||||
if not change_status(token, initiative_id, 2, 'Chuyển sang xét cấp khoa'):
|
||||
return False
|
||||
if target == 2:
|
||||
return True
|
||||
if target == 5:
|
||||
return reject(token, initiative_id)
|
||||
if not change_status(token, initiative_id, 3, 'Chuyển sang xét cấp trường'):
|
||||
return False
|
||||
if target == 3:
|
||||
return True
|
||||
if not approve(token, initiative_id):
|
||||
return False
|
||||
if target == 4:
|
||||
return True
|
||||
# Move to finalized via report phase (4 -> 6 -> 7)
|
||||
if target == 7:
|
||||
if not change_status(token, initiative_id, 6, 'Báo cáo đang duyệt'):
|
||||
return False
|
||||
if not change_status(token, initiative_id, 7, 'Hoàn tất'):
|
||||
return False
|
||||
return True
|
||||
|
||||
def main():
|
||||
print('=== DYD Sample Data Seeder ===')
|
||||
print('Logging in as admin...')
|
||||
admin_token, admin_id = login(ADMIN_EMAIL, ADMIN_PASSWORD)
|
||||
print(f'Admin: {admin_id[:8]}...')
|
||||
|
||||
# Create test users
|
||||
print('\n=== Creating test users ===')
|
||||
test_users = [
|
||||
('reviewer1@dhyd.local', 'Review@2026', 'Trần Thị Bình - Trưởng khoa Y', 'Editor'),
|
||||
('reviewer2@dhyd.local', 'Review@2026', 'Nguyễn Văn An - Trưởng khoa Dược', 'Editor'),
|
||||
('gv1@dhyd.local', 'User@123456', 'TS. Lê Hoàng Minh - Giảng viên Khoa Y', 'Viewer'),
|
||||
('gv2@dhyd.local', 'User@123456', 'ThS. Phạm Thu Hà - Giảng viên Khoa Dược', 'Viewer'),
|
||||
('gv3@dhyd.local', 'User@123456', 'BS. Võ Thanh Tùng - Giảng viên Khoa Điều Dưỡng', 'Viewer'),
|
||||
]
|
||||
for email, pwd, name, role in test_users:
|
||||
register_user(email, pwd, name, role, admin_token)
|
||||
|
||||
# Get units
|
||||
print('\n=== Loading units ===')
|
||||
units = get_units(admin_token)
|
||||
unit_by_code = {u['code']: u for u in units}
|
||||
for u in units:
|
||||
print(f' {u["code"]}: {u["name"]}')
|
||||
|
||||
# Create + advance each initiative
|
||||
print(f'\n=== Creating {len(INITIATIVES)} initiatives ===')
|
||||
for idx, init in enumerate(INITIATIVES, 1):
|
||||
unit = unit_by_code.get(init['unit_code'])
|
||||
if not unit:
|
||||
print(f'[SKIP] Unit {init["unit_code"]} not found')
|
||||
continue
|
||||
|
||||
print(f'\n[{idx}/{len(INITIATIVES)}] {init["title"][:60]}...')
|
||||
payload = {
|
||||
'title': init['title'],
|
||||
'shortSummary': init['shortSummary'],
|
||||
'description': init['description'],
|
||||
'objectives': init['objectives'],
|
||||
'scopeOfApplication': init['scopeOfApplication'],
|
||||
'expectedOutcomes': init['expectedOutcomes'],
|
||||
'category': init['category'],
|
||||
'group': init['group'],
|
||||
'owningUnitId': unit['id'],
|
||||
'estimatedBudget': init['estimatedBudget'],
|
||||
'authors': [{
|
||||
'authorId': '00000000-0000-0000-0000-000000000000',
|
||||
'fullName': 'Admin Test',
|
||||
'email': 'admin@dhyd.local',
|
||||
'position': 'Quản trị viên',
|
||||
'academicTitle': 'Tiến sĩ',
|
||||
'contributionPercentage': 100,
|
||||
'isLeadAuthor': True,
|
||||
'contributionDescription': 'Chủ nhiệm đề tài',
|
||||
}],
|
||||
}
|
||||
|
||||
initiative_id = create_initiative(admin_token, payload)
|
||||
if initiative_id:
|
||||
target = init['target_status']
|
||||
if target != 0:
|
||||
success = advance_to_status(admin_token, initiative_id, target)
|
||||
status_name = {0:'Nháp',1:'Đã nộp',2:'Xét cấp khoa',3:'Xét cấp trường',4:'Đã duyệt',5:'Từ chối',6:'Đang báo cáo',7:'Hoàn tất'}[target]
|
||||
print(f' → Target status: {status_name} ({target}) — {"OK" if success else "FAIL"}')
|
||||
|
||||
print('\n✅ Seed complete!')
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print(f'ERROR: {e}')
|
||||
sys.exit(1)
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Install a Gitea Actions runner (act_runner) on THIS host for tlam89/sciagent.
|
||||
# Target: the Gitea + app box 103.149.170.102 (run this ON that box, as root).
|
||||
#
|
||||
# Registers ONE runner with two labels so the CI/CD pipeline can use both:
|
||||
# ci -> docker mode (clean ephemeral containers; backend + frontend jobs)
|
||||
# deploy -> host mode (runs on the host so it can drive `docker compose`)
|
||||
#
|
||||
# Prereqs installed/verified here: Docker + compose plugin, Node.js (needed by
|
||||
# host-mode actions/checkout). Re-runnable: skips steps already done.
|
||||
#
|
||||
# Usage (token already minted for this repo; rotate via Gitea if it expires):
|
||||
# sudo bash setup-gitea-runner.sh
|
||||
# Override:
|
||||
# GITEA_URL=http://localhost:3000 RUNNER_TOKEN=xxx bash setup-gitea-runner.sh
|
||||
# ============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="${GITEA_URL:-http://localhost:3000}"
|
||||
RUNNER_TOKEN="${RUNNER_TOKEN:-aCd3rpPnJTAPVHqsTIme9SaJm3pUbnbyOQqMrt9O}"
|
||||
RUNNER_NAME="${RUNNER_NAME:-sciagent-box-runner}"
|
||||
RUNNER_LABELS="${RUNNER_LABELS:-ci:docker://catthehacker/ubuntu:act-22.04,deploy:host}"
|
||||
ACT_VERSION="${ACT_VERSION:-0.2.11}"
|
||||
RUNNER_DIR="${RUNNER_DIR:-/opt/act_runner}"
|
||||
|
||||
log(){ printf '\n\033[36m==> %s\033[0m\n' "$*"; }
|
||||
|
||||
[[ "$(id -u)" -eq 0 ]] || { echo "Run as root (sudo)."; exit 1; }
|
||||
|
||||
# --- 1. Docker + compose plugin --------------------------------------------
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
log "Installing Docker (get.docker.com convenience script)"
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
fi
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
log "docker compose plugin missing — installing docker-compose-plugin"
|
||||
(apt-get update && apt-get install -y docker-compose-plugin) || \
|
||||
echo "WARN: install the compose plugin manually for your distro."
|
||||
fi
|
||||
systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
docker version --format '{{.Server.Version}}' >/dev/null 2>&1 || { echo "Docker daemon not running"; exit 1; }
|
||||
|
||||
# --- 2. Node.js (host-mode jobs run actions/checkout, which needs node) -----
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
log "Installing Node.js 20"
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs || \
|
||||
echo "WARN: install Node.js 20 manually if your distro is non-Debian."
|
||||
fi
|
||||
|
||||
# --- 3. Download act_runner -------------------------------------------------
|
||||
mkdir -p "$RUNNER_DIR"
|
||||
cd "$RUNNER_DIR"
|
||||
if [[ ! -x "$RUNNER_DIR/act_runner" ]]; then
|
||||
log "Downloading act_runner ${ACT_VERSION}"
|
||||
arch="$(uname -m)"; case "$arch" in x86_64) arch=amd64;; aarch64) arch=arm64;; esac
|
||||
curl -fsSL -o act_runner \
|
||||
"https://dl.gitea.com/act_runner/${ACT_VERSION}/act_runner-${ACT_VERSION}-linux-${arch}"
|
||||
chmod +x act_runner
|
||||
fi
|
||||
|
||||
# --- 4. Register ------------------------------------------------------------
|
||||
if [[ ! -f "$RUNNER_DIR/.runner" ]]; then
|
||||
log "Registering with Gitea ($GITEA_URL) labels: $RUNNER_LABELS"
|
||||
./act_runner register --no-interactive \
|
||||
--instance "$GITEA_URL" \
|
||||
--token "$RUNNER_TOKEN" \
|
||||
--name "$RUNNER_NAME" \
|
||||
--labels "$RUNNER_LABELS"
|
||||
else
|
||||
log ".runner already exists — skipping register"
|
||||
fi
|
||||
[[ -f "$RUNNER_DIR/config.yaml" ]] || ./act_runner generate-config > config.yaml
|
||||
|
||||
# generate-config injects DEFAULT labels (ubuntu-latest/22.04/20.04) into config.yaml
|
||||
# which OVERRIDE the --labels passed at registration. Force our labels into config.yaml
|
||||
# so the daemon advertises ci (docker) + deploy (host), not the defaults.
|
||||
python3 - "$RUNNER_DIR/config.yaml" "$RUNNER_LABELS" <<'PY'
|
||||
import sys,re
|
||||
path,labels=sys.argv[1],sys.argv[2].split(",")
|
||||
s=open(path).read().splitlines(keepends=True)
|
||||
o=[];i=0;n=len(s);done=False
|
||||
while i<n:
|
||||
if not done and re.match(r'^\s{2}labels:\s*$',s[i]):
|
||||
o.append(" labels:\n")
|
||||
for x in labels: o.append(' - "%s"\n'%x)
|
||||
i+=1
|
||||
while i<n and re.match(r'^\s{4}(- |#)',s[i]): i+=1
|
||||
done=True; continue
|
||||
o.append(s[i]); i+=1
|
||||
if not done:
|
||||
o2=[];ins=False
|
||||
for line in o:
|
||||
o2.append(line)
|
||||
if not ins and re.match(r'^runner:\s*$',line):
|
||||
o2.append(" labels:\n")
|
||||
for x in labels: o2.append(' - "%s"\n'%x)
|
||||
ins=True
|
||||
o=o2
|
||||
open(path,"w").write("".join(o))
|
||||
print("config.yaml labels ->", ", ".join(labels))
|
||||
PY
|
||||
|
||||
# --- 5. systemd service -----------------------------------------------------
|
||||
log "Installing systemd service act_runner"
|
||||
cat > /etc/systemd/system/act_runner.service <<UNIT
|
||||
[Unit]
|
||||
Description=Gitea Actions runner (sciagent)
|
||||
After=docker.service network-online.target
|
||||
Wants=docker.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=${RUNNER_DIR}
|
||||
ExecStart=${RUNNER_DIR}/act_runner daemon --config ${RUNNER_DIR}/config.yaml
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now act_runner
|
||||
sleep 3
|
||||
systemctl --no-pager --full status act_runner | head -12
|
||||
|
||||
echo
|
||||
echo "Done. Verify in Gitea: Repo → Settings → Actions → Runners — '$RUNNER_NAME' should be Online."
|
||||
echo "Logs: journalctl -u act_runner -f"
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Split file gốc "bao-cao-template-original.docx" thành 5 template riêng biệt:
|
||||
- mau-01-bao-cao-mo-ta.docx (includes 2 cover pages)
|
||||
- mau-02-don-de-nghi.docx
|
||||
- mau-03-xac-nhan-ty-le.docx
|
||||
- mau-04-phieu-danh-gia.docx
|
||||
- ban-cam-ket.docx
|
||||
|
||||
Strategy: unzip original → parse document.xml → state-machine iterate body children
|
||||
(p, tbl, ...), group by "Mẫu số 0X" markers → write 5 output docx bằng cách clone
|
||||
unpacked directory và thay document.xml body children cho mỗi template.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
NS_URI = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
W = f'{{{NS_URI}}}'
|
||||
ET.register_namespace('w', NS_URI)
|
||||
# Also register common namespaces in docx
|
||||
EXTRA_NS = {
|
||||
'wpc': 'http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas',
|
||||
'mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
|
||||
'o': 'urn:schemas-microsoft-com:office:office',
|
||||
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
|
||||
'v': 'urn:schemas-microsoft-com:vml',
|
||||
'wp14': 'http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing',
|
||||
'wp': 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing',
|
||||
'w10': 'urn:schemas-microsoft-com:office:word',
|
||||
'w14': 'http://schemas.microsoft.com/office/word/2010/wordml',
|
||||
'w15': 'http://schemas.microsoft.com/office/word/2012/wordml',
|
||||
'wpg': 'http://schemas.microsoft.com/office/word/2010/wordprocessingGroup',
|
||||
'wpi': 'http://schemas.microsoft.com/office/word/2010/wordprocessingInk',
|
||||
'wne': 'http://schemas.microsoft.com/office/word/2006/wordml',
|
||||
'wps': 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape',
|
||||
}
|
||||
for prefix, uri in EXTRA_NS.items():
|
||||
ET.register_namespace(prefix, uri)
|
||||
|
||||
|
||||
def get_para_text(p):
|
||||
return ''.join((t.text or '') for t in p.iter(f'{W}t')).strip()
|
||||
|
||||
|
||||
import re
|
||||
|
||||
HEADER_PREFIXES = (
|
||||
'BỘ Y TẾ',
|
||||
'ĐẠI HỌC Y DƯỢC',
|
||||
'THÀNH PHỐ HỒ CHÍ MINH',
|
||||
'ĐƠN VỊ:',
|
||||
'ĐƠN VỊ ',
|
||||
'CỘNG HÒA XÃ HỘI',
|
||||
'CÔNG HÒA XÃ HỘI',
|
||||
'CỘNG HOÀ XÃ HỘI',
|
||||
'CỘNG HOÀ XÃ HỘI CHỦ NGHĨA',
|
||||
'Độc lập',
|
||||
'TP. Hồ Chí Minh',
|
||||
'Tp. Hồ Chí Minh',
|
||||
'Thành phố Hồ Chí Minh',
|
||||
)
|
||||
|
||||
|
||||
def is_header_like(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
for p in HEADER_PREFIXES:
|
||||
if text.startswith(p):
|
||||
return True
|
||||
return bool(re.match(r'^Mẫu\s*số\s*\d', text))
|
||||
|
||||
|
||||
def find_section_markers(children):
|
||||
"""
|
||||
Scan children, tìm marker paragraphs "Mẫu số 0X" / "BẢN CAM KẾT" + walk back
|
||||
để include header block (BỘ Y TẾ / ĐẠI HỌC Y DƯỢC / ...). Return dict
|
||||
section_name → start_index.
|
||||
"""
|
||||
section_starts = {'mau01': 0} # mau01 bao gồm cover + content
|
||||
|
||||
for i, child in enumerate(children):
|
||||
tag = child.tag.replace(W, '')
|
||||
if tag != 'p':
|
||||
continue
|
||||
text = get_para_text(child)
|
||||
if not text:
|
||||
continue
|
||||
|
||||
section_key = None
|
||||
if 'BẢN CAM KẾT' in text.upper():
|
||||
section_key = 'camket'
|
||||
else:
|
||||
m = re.match(r'^Mẫu\s*số\s*(0[234])', text)
|
||||
if m:
|
||||
section_key = f'mau{m.group(1)}'
|
||||
|
||||
if section_key is None or section_key in section_starts:
|
||||
continue
|
||||
|
||||
# Walk back: include paragraphs + tables chứa header text
|
||||
start = i
|
||||
for j in range(i - 1, -1, -1):
|
||||
prev = children[j]
|
||||
tag = prev.tag.replace(W, '')
|
||||
ptext = get_para_text(prev).strip()
|
||||
if tag == 'p':
|
||||
if ptext == '' or is_header_like(ptext):
|
||||
start = j
|
||||
else:
|
||||
break
|
||||
elif tag == 'tbl':
|
||||
# Header table bắt buộc có "BỘ Y TẾ" hoặc "ĐẠI HỌC Y DƯỢC" hoặc
|
||||
# "CỘNG HÒA/HOÀ XÃ HỘI" (strong markers). Tránh nhầm với signature
|
||||
# tbl của mẫu trước (chỉ có "Tp. Hồ Chí Minh, ngày...").
|
||||
strong_markers = (
|
||||
'BỘ Y TẾ',
|
||||
'ĐẠI HỌC Y DƯỢC',
|
||||
'CỘNG HÒA XÃ HỘI',
|
||||
'CỘNG HOÀ XÃ HỘI',
|
||||
'CÔNG HÒA XÃ HỘI',
|
||||
)
|
||||
if any(mk in ptext for mk in strong_markers):
|
||||
start = j
|
||||
else:
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
section_starts[section_key] = start
|
||||
|
||||
return section_starts
|
||||
|
||||
|
||||
def split_document(original_path: Path, output_dir: Path):
|
||||
"""Split original docx into 5 templates."""
|
||||
with tempfile.TemporaryDirectory() as tmp_str:
|
||||
tmp = Path(tmp_str)
|
||||
# Extract original
|
||||
base_dir = tmp / 'base'
|
||||
base_dir.mkdir()
|
||||
with zipfile.ZipFile(original_path, 'r') as z:
|
||||
z.extractall(base_dir)
|
||||
|
||||
# Parse document.xml
|
||||
doc_xml_path = base_dir / 'word' / 'document.xml'
|
||||
tree = ET.parse(doc_xml_path)
|
||||
root = tree.getroot()
|
||||
body = root.find(f'{W}body')
|
||||
assert body is not None, 'No body element'
|
||||
|
||||
# Collect sectPr (preserve for each output)
|
||||
sect_pr = None
|
||||
children = list(body)
|
||||
content_children = [] # children excluding sectPr
|
||||
for child in children:
|
||||
if child.tag.replace(W, '') == 'sectPr':
|
||||
sect_pr = child
|
||||
else:
|
||||
content_children.append(child)
|
||||
|
||||
# Find section start indices (với walk-back include header block)
|
||||
starts = find_section_markers(content_children)
|
||||
# Build ordered list: mau01 at 0, then other sections sorted by start index
|
||||
ordered = sorted(starts.items(), key=lambda kv: kv[1])
|
||||
|
||||
# Build sections dict — assign children [start_i, start_{i+1}) to each section
|
||||
sections = {k: [] for k in ['mau01', 'mau02', 'mau03', 'mau04', 'camket']}
|
||||
for idx, (sec_name, start) in enumerate(ordered):
|
||||
end = ordered[idx + 1][1] if idx + 1 < len(ordered) else len(content_children)
|
||||
sections[sec_name] = content_children[start:end]
|
||||
|
||||
# Write 5 output files
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
outputs = [
|
||||
('mau-01-bao-cao-mo-ta.docx', 'mau01'),
|
||||
('mau-02-don-de-nghi.docx', 'mau02'),
|
||||
('mau-03-xac-nhan-ty-le.docx', 'mau03'),
|
||||
('mau-04-phieu-danh-gia.docx', 'mau04'),
|
||||
('ban-cam-ket.docx', 'camket'),
|
||||
]
|
||||
|
||||
for filename, section_key in outputs:
|
||||
out_path = output_dir / filename
|
||||
# Clone base directory
|
||||
clone_dir = tmp / f'clone-{section_key}'
|
||||
shutil.copytree(base_dir, clone_dir)
|
||||
|
||||
# Modify document.xml
|
||||
clone_doc = clone_dir / 'word' / 'document.xml'
|
||||
ctree = ET.parse(clone_doc)
|
||||
croot = ctree.getroot()
|
||||
cbody = croot.find(f'{W}body')
|
||||
assert cbody is not None
|
||||
|
||||
# Clear existing body children
|
||||
for c in list(cbody):
|
||||
cbody.remove(c)
|
||||
|
||||
# Add section-specific children
|
||||
for elem in sections[section_key]:
|
||||
cbody.append(elem)
|
||||
# Add sectPr last
|
||||
if sect_pr is not None:
|
||||
cbody.append(sect_pr)
|
||||
|
||||
ctree.write(clone_doc, encoding='UTF-8', xml_declaration=True)
|
||||
|
||||
# Rezip
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
with zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for file_path in clone_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
zout.write(file_path, file_path.relative_to(clone_dir))
|
||||
|
||||
count = len(sections[section_key])
|
||||
print(f'Wrote {out_path.name} ({count} body children)')
|
||||
|
||||
|
||||
def main():
|
||||
repo_root = Path(__file__).parent.parent
|
||||
original = repo_root / 'src/Backend/DYD.Api/Templates/bao-cao-template-original.docx'
|
||||
output_dir = repo_root / 'src/Backend/DYD.Api/Templates'
|
||||
|
||||
if not original.exists():
|
||||
print(f'ERROR: source template not found: {original}')
|
||||
sys.exit(1)
|
||||
|
||||
split_document(original, output_dir)
|
||||
print('\nDone. 5 template files created.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,22 @@
|
||||
@echo off
|
||||
REM Khởi động cả 2 frontend (fe0 + fe-admin) trong 2 cửa sổ riêng
|
||||
REM Yêu cầu: Node.js 20+ đã cài
|
||||
|
||||
setlocal
|
||||
set ROOT=%~dp0..
|
||||
|
||||
echo [1/2] Khởi động fe0 (User frontend) - http://localhost:8080
|
||||
start "fe0 - User Frontend" cmd /k "cd /d %ROOT%\fe0 && (if not exist node_modules npm install) && npm run dev"
|
||||
|
||||
timeout /t 2 /nobreak >nul
|
||||
|
||||
echo [2/2] Khởi động fe-admin (Admin frontend) - http://localhost:8082
|
||||
start "fe-admin - Admin Frontend" cmd /k "cd /d %ROOT%\fe-admin && (if not exist node_modules npm install) && npm run dev"
|
||||
|
||||
echo.
|
||||
echo Da khoi dong 2 frontend trong 2 cua so rieng.
|
||||
echo - User UI: http://localhost:8080
|
||||
echo - Admin UI: http://localhost:8082
|
||||
echo.
|
||||
echo Backend .NET (DYD.Api) chay rieng tu Visual Studio (F5).
|
||||
endlocal
|
||||
@@ -0,0 +1,24 @@
|
||||
# Khởi động cả 2 frontend (fe0 + fe-admin) trong 2 PowerShell window riêng
|
||||
# Yêu cầu: Node.js 20+ đã cài
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
function Start-Frontend {
|
||||
param([string]$Name, [string]$Path, [int]$Port)
|
||||
|
||||
Write-Host "[$Name] Khởi động trên port $Port..." -ForegroundColor Cyan
|
||||
$cmd = "Set-Location '$Path'; if (-not (Test-Path node_modules)) { npm install }; npm run dev"
|
||||
Start-Process pwsh -ArgumentList '-NoExit', '-Command', $cmd -WindowStyle Normal
|
||||
}
|
||||
|
||||
Start-Frontend -Name 'fe0 (User)' -Path "$root\fe0" -Port 8080
|
||||
Start-Sleep -Seconds 2
|
||||
Start-Frontend -Name 'fe-admin (Admin)' -Path "$root\fe-admin" -Port 8082
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "✅ Đã khởi động 2 frontend:" -ForegroundColor Green
|
||||
Write-Host " - User UI: http://localhost:8080"
|
||||
Write-Host " - Admin UI: http://localhost:8082"
|
||||
Write-Host ""
|
||||
Write-Host "Backend .NET (DYD.Api) chạy riêng từ Visual Studio (F5)." -ForegroundColor Yellow
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fix B: set the Postgres role password to match POSTGRES_PASSWORD in .env (no volume wipe).
|
||||
#
|
||||
# Works when `psql` inside the container can connect to the server over the Unix socket
|
||||
# without a password (common for the official postgres image’s local/trust rules). The app
|
||||
# role (POSTGRES_USER) can change its own password, or you can set POSTGRES_SUPERUSER to
|
||||
# connect as a different superuser (e.g. postgres) when that role still exists.
|
||||
#
|
||||
# Usage (repo root, stack can be up):
|
||||
# ./scripts/sync-postgres-app-password.sh
|
||||
#
|
||||
# Optional .env entries:
|
||||
# POSTGRES_SUPERUSER=postgres # DB role to connect as (default: same as POSTGRES_USER)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE=(docker compose --env-file "${ROOT}/.env" -f "${ROOT}/docker-compose.prod.yml")
|
||||
cd "$ROOT"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-$ROOT/.env}"
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
printf 'Missing %s\n' "$ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
val="${val#"${val%%[![:space:]]*}"}"
|
||||
val="${val%"${val##*[![:space:]]}"}"
|
||||
export "${key}=${val}"
|
||||
fi
|
||||
done <"$ENV_FILE"
|
||||
|
||||
if [[ -z "${POSTGRES_USER:-}" || -z "${POSTGRES_PASSWORD:-}" ]]; then
|
||||
printf 'POSTGRES_USER and POSTGRES_PASSWORD must be set in %s\n' "$ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# SQL string literal: escape single quotes per PostgreSQL rules (' -> '')
|
||||
sql_escape_literal() {
|
||||
printf '%s' "$1" | sed "s/'/''/g"
|
||||
}
|
||||
|
||||
# Double-quoted identifier: " -> "
|
||||
sql_escape_ident() {
|
||||
printf '%s' "$1" | sed 's/"/""/g'
|
||||
}
|
||||
|
||||
conn_role="${POSTGRES_SUPERUSER:-$POSTGRES_USER}"
|
||||
pass_esc="$(sql_escape_literal "$POSTGRES_PASSWORD")"
|
||||
user_ident="$(sql_escape_ident "$POSTGRES_USER")"
|
||||
|
||||
sql="ALTER ROLE \"${user_ident}\" WITH PASSWORD '${pass_esc}';"
|
||||
|
||||
printf '→ Connecting as Postgres role %q (inside container), updating password for role %q…\n' "$conn_role" "$POSTGRES_USER"
|
||||
|
||||
if "${COMPOSE[@]}" exec -T postgres psql -U "$conn_role" -d postgres -v ON_ERROR_STOP=1 -c "$sql"; then
|
||||
printf 'OK — role %q password now matches POSTGRES_PASSWORD in .env.\nRestart be0 if it was crash-looping:\n %s restart be0\n' "$POSTGRES_USER" "${COMPOSE[*]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '\nThat failed. Common causes:\n' >&2
|
||||
printf ' • This cluster has no local "trust" for psql — supply the current password and run manually:\n' >&2
|
||||
printf ' PGPASSWORD="(old)" docker compose --env-file .env -f docker-compose.prod.yml exec -T postgres \\\n' >&2
|
||||
printf ' psql -U %q -d postgres -c "ALTER ROLE ..."\n' "$POSTGRES_USER" >&2
|
||||
printf ' • Role %q is not a superuser and you connected as the wrong POSTGRES_SUPERUSER — set POSTGRES_SUPERUSER in .env to a superuser that exists.\n' "$POSTGRES_USER" >&2
|
||||
printf ' • See docs/deploy-production-docker.md §3\n' >&2
|
||||
exit 1
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-test verify-prod-env.sh rejects insecure values.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp)"
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
|
||||
cat >"$TMP" <<'EOF'
|
||||
PUBLIC_HOST=example.com
|
||||
FE_PORT=8081
|
||||
MINIO_API_PORT=19000
|
||||
MINIO_CONSOLE_PORT=19001
|
||||
MINIO_ROOT_USER=minio_user
|
||||
MINIO_ROOT_PASSWORD=long_random_minio_secret_value
|
||||
POSTGRES_USER=initiative
|
||||
POSTGRES_PASSWORD=long_random_postgres_secret
|
||||
POSTGRES_DB=initiatives
|
||||
JWT_SECRET=short
|
||||
MINIO_API_CORS_ALLOW_ORIGIN=*
|
||||
EOF
|
||||
|
||||
if ENV_FILE="$TMP" "$ROOT/scripts/verify-prod-env.sh" >/dev/null 2>&1; then
|
||||
echo "FAIL: verify-prod-env.sh should reject short JWT_SECRET and CORS *" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat >"$TMP" <<'EOF'
|
||||
PUBLIC_HOST=example.com
|
||||
FE_PORT=8081
|
||||
MINIO_API_PORT=19000
|
||||
MINIO_CONSOLE_PORT=19001
|
||||
MINIO_ROOT_USER=minio_user
|
||||
MINIO_ROOT_PASSWORD=long_random_minio_secret_value
|
||||
POSTGRES_USER=initiative
|
||||
POSTGRES_PASSWORD=long_random_postgres_secret
|
||||
POSTGRES_DB=initiatives
|
||||
JWT_SECRET=this-is-a-valid-production-jwt-secret-32chars-min
|
||||
MINIO_API_CORS_ALLOW_ORIGIN=https://example.com
|
||||
EOF
|
||||
|
||||
ENV_FILE="$TMP" "$ROOT/scripts/verify-prod-env.sh" >/dev/null
|
||||
echo "OK — verify-prod-env.sh accepts secure sample .env"
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validates variables required by docker-compose.prod.yml before compose runs.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-$ROOT/.env}"
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
printf 'Missing %s\nCopy .env.example to .env and fill in secrets.\n' "$ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
val="${val#"${val%%[![:space:]]*}"}"
|
||||
val="${val%"${val##*[![:space:]]}"}"
|
||||
export "${key}=${val}"
|
||||
fi
|
||||
done <"$ENV_FILE"
|
||||
|
||||
reject_leading_space_after_equals() {
|
||||
local key rhs
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||
if [[ "$line" =~ ^[[:space:]]*(POSTGRES_USER|POSTGRES_PASSWORD|POSTGRES_DB|MINIO_ROOT_USER|MINIO_ROOT_PASSWORD|MINIO_API_PORT|MINIO_CONSOLE_PORT|FE_PORT|PUBLIC_HOST)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
rhs="${BASH_REMATCH[2]}"
|
||||
[[ "$rhs" =~ ^[[:space:]]+ ]] || continue
|
||||
printf 'Invalid format in %s: "%s" has a space (or tab) immediately after "=".\n' "$ENV_FILE" "$key" >&2
|
||||
printf 'Use KEY=value without a space after the equals sign.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
done <"$ENV_FILE"
|
||||
}
|
||||
reject_leading_space_after_equals
|
||||
MISSING=()
|
||||
need() {
|
||||
local n="$1"
|
||||
local v="${!n-}"
|
||||
if [[ -z "${v//[:space:]}" ]]; then
|
||||
MISSING+=("$n")
|
||||
fi
|
||||
}
|
||||
|
||||
need PUBLIC_HOST
|
||||
need FE_PORT
|
||||
need MINIO_API_PORT
|
||||
need MINIO_CONSOLE_PORT
|
||||
need MINIO_ROOT_USER
|
||||
need MINIO_ROOT_PASSWORD
|
||||
need POSTGRES_USER
|
||||
need POSTGRES_PASSWORD
|
||||
need POSTGRES_DB
|
||||
need JWT_SECRET
|
||||
need MINIO_API_CORS_ALLOW_ORIGIN
|
||||
|
||||
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
||||
printf 'Unset or whitespace-only variables in %s:\n %s\n' "$ENV_FILE" "${MISSING[*]}" >&2
|
||||
printf '(Blank values break MinIO: MINIO_BROWSER_REDIRECT_URL would have an empty host.)\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "${MINIO_CONSOLE_PORT}" =~ ^[0-9]+$ ]] || ! [[ "${MINIO_API_PORT}" =~ ^[0-9]+$ ]] || ! [[ "${FE_PORT}" =~ ^[0-9]+$ ]]; then
|
||||
printf 'Ports must be numeric (FE_PORT, MINIO_*_PORT).\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Postgres role/database names in URLs/scripts: avoid quoting and “role does not exist” churn.
|
||||
if ! [[ "${POSTGRES_USER}" =~ ^[a-zA-Z_][a-zA-Z0-9_]{0,62}$ ]]; then
|
||||
printf 'POSTGRES_USER must be an unquoted SQL identifier (e.g. initiative): letters, digits, underscore; max 63 chars.\n' >&2
|
||||
printf 'Avoid special characters like "!". Docker only creates this role when the Postgres data directory is EMPTY.\n' >&2
|
||||
printf 'Full guide: docs/deploy-production-docker.md (Postgres).\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "${POSTGRES_DB}" =~ ^[a-zA-Z_][a-zA-Z0-9_]{0,62}$ ]]; then
|
||||
printf 'POSTGRES_DB must be an unquoted SQL identifier (e.g. initiatives): letters, digits, underscore; max 63 chars.\n' >&2
|
||||
printf 'INITIATIVE_DATABASE_URL is built without escaping; exotic names break URLs.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# INITIATIVE_DATABASE_URL is assembled without encoding; :, @, / break the URL if unescaped.
|
||||
if [[ "${POSTGRES_PASSWORD}" == *"@"* ]] || [[ "${POSTGRES_PASSWORD}" == *":"* ]] || [[ "${POSTGRES_PASSWORD}" == *"/"* ]] || [[ "${POSTGRES_PASSWORD}" == *"%"* ]]; then
|
||||
printf 'POSTGRES_PASSWORD contains @, :, / or %%. URL-unsafe for INITIATIVE_DATABASE_URL; use alphanumeric + punctuation like + or = from openssl rand -base64.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${MINIO_API_CORS_ALLOW_ORIGIN}" == "*" ]]; then
|
||||
printf 'MINIO_API_CORS_ALLOW_ORIGIN must not be * in production — use your SPA origin (e.g. https://www.rcc-ump.com).\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ${#JWT_SECRET} -lt 32 ]]; then
|
||||
printf 'JWT_SECRET must be at least 32 characters (generate: openssl rand -base64 48).\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
preview="http://${PUBLIC_HOST}:${MINIO_CONSOLE_PORT}"
|
||||
|
||||
echo "OK — required vars set; MINIO_BROWSER_REDIRECT_URL preview: ${preview}"
|
||||
echo " CORS for public UI: http://${PUBLIC_HOST}:${FE_PORT} (+ localhost defaults in be0; optional CORS_ORIGINS_EXTRA in .env)"
|
||||
if [[ -n "${S3_PUBLIC_ENDPOINT_URL:-}" && "${S3_PUBLIC_ENDPOINT_URL}" == https:* ]]; then
|
||||
echo " HTTPS MinIO (S3_PUBLIC_ENDPOINT_URL): ensure TLS proxy → 127.0.0.1:${MINIO_API_PORT}; see docs/minio-behind-https.md"
|
||||
fi
|
||||
if [[ -n "${MINIO_SERVER_URL:-}" && "${MINIO_SERVER_URL}" == https:* && -z "${S3_PUBLIC_ENDPOINT_URL:-}" ]]; then
|
||||
echo " Warn: MINIO_SERVER_URL is https but S3_PUBLIC_ENDPOINT_URL is unset — be0 defaults may still presign HTTP. Set both to match."
|
||||
fi
|
||||
Reference in New Issue
Block a user