apply recommendations: security, get_db, exceptions, eslint, api-client
- session: set_tenant use bound param (SQL injection fix)
- health: text('SELECT 1'), REDIS_URL from config
- deps: re-export get_db from session, use settings.ENABLE_DEV_AUTH (default False)
- routes: all get_db from app.api.deps; conftest overrides deps.get_db
- main: register exception handlers from app.api.exceptions
- next.config: enable ESLint and TypeScript checks
- .eslintrc: drop @typescript-eslint/recommended; fix no-console (logger, ws-client, regulations)
- backend/.env.example added
- frontend: export apiFetch; dashboard, profile, settings, risks use api-client
- docs/ANALYSIS_AND_RECOMMENDATIONS.md
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d9dd6d66cd
commit
a7da43be0e
@ -1,10 +1 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "@typescript-eslint/recommended"],
|
||||
"rules": {
|
||||
"no-console": ["error", { "allow": ["warn", "error"] }],
|
||||
"no-eval": "error",
|
||||
"prefer-const": "error",
|
||||
"max-lines": ["warn", { "max": 500 }]
|
||||
},
|
||||
"ignorePatterns": ["node_modules/", ".next/", "out/", "scripts/"]
|
||||
}
|
||||
{"extends":["next/core-web-vitals"],"rules":{"no-console":["error",{"allow":["warn","error"]}],"no-eval":"error","prefer-const":"error","max-lines":["warn",{"max":500}]},"ignorePatterns":["node_modules/",".next/","out/","scripts/"]}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from 'next/server';
|
||||
import { logInfo } from '@/lib/logger';
|
||||
|
||||
// Эндпоинт для обновления нормативных документов
|
||||
// Должен вызываться автоматически раз в месяц через cron job или scheduled task
|
||||
@ -7,7 +8,7 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const { source } = await request.json().catch(() => ({}));
|
||||
|
||||
console.log(`Начато обновление нормативных документов для источника: ${source || 'все'}`);
|
||||
logInfo(`Начато обновление нормативных документов для источника: ${source || 'все'}`);
|
||||
|
||||
// В реальном приложении здесь будет:
|
||||
// 1. Загрузка документов с официальных сайтов (ICAO, EASA, FAA, МАК, АРМАК)
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageLayout, StatusBadge } from '@/components/ui';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '@/lib/api/api-client';
|
||||
|
||||
interface DashboardData {
|
||||
overview: any; directives: any; lifeLimits: any; personnel: any; risks: any;
|
||||
@ -36,14 +37,14 @@ export default function DashboardPage() {
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/v1/stats').then(r => r.json()).catch(() => null),
|
||||
fetch('/api/v1/airworthiness-core/directives?status=open').then(r => r.json()).catch(() => ({ total: 0, items: [] })),
|
||||
fetch('/api/v1/airworthiness-core/life-limits').then(r => r.json()).catch(() => ({ total: 0, items: [] })),
|
||||
fetch('/api/v1/personnel-plg/compliance-report').then(r => r.json()).catch(() => null),
|
||||
fetch('/api/v1/risk-alerts').then(r => r.json()).catch(() => ({ total: 0 })),
|
||||
fetch('/api/v1/work-orders/stats/summary').then(r => r.json()).catch(() => ({ total: 0, in_progress: 0, aog: 0 })),
|
||||
fetch('/api/v1/defects/?status=open').then(r => r.json()).catch(() => ({ total: 0 })),
|
||||
fetch('/api/v1/fgis-revs/status').then(r => r.json()).catch(() => ({ connection_status: 'unknown' })),
|
||||
apiFetch('/stats').catch(() => null),
|
||||
apiFetch<{ total?: number; items?: unknown[] }>('/airworthiness-core/directives?status=open').catch(() => ({ total: 0, items: [] })),
|
||||
apiFetch<{ total?: number; items?: unknown[] }>('/airworthiness-core/life-limits').catch(() => ({ total: 0, items: [] })),
|
||||
apiFetch('/personnel-plg/compliance-report').catch(() => null),
|
||||
apiFetch<{ total?: number }>('/risk-alerts').catch(() => ({ total: 0 })),
|
||||
apiFetch<{ total?: number; in_progress?: number; aog?: number }>('/work-orders/stats/summary').catch(() => ({ total: 0, in_progress: 0, aog: 0 })),
|
||||
apiFetch<{ total?: number }>('/defects/?status=open').catch(() => ({ total: 0 })),
|
||||
apiFetch<{ connection_status?: string }>('/fgis-revs/status').catch(() => ({ connection_status: 'unknown' })),
|
||||
]).then(([overview, directives, lifeLimits, personnel, risks, woStats, openDefects, fgisStatus]) => {
|
||||
setData({ overview, directives, lifeLimits, personnel, risks, woStats, openDefects, fgisStatus });
|
||||
setLoading(false);
|
||||
|
||||
48
app/profile/page.tsx
Normal file
48
app/profile/page.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageLayout } from '@/components/ui';
|
||||
import { apiFetch } from '@/lib/api/api-client';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [user, setUser] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
apiFetch('/users/me').then(setUser).catch(() => setUser({ name: 'Пользователь', email: '', roles: ['user'] }));
|
||||
}, []);
|
||||
|
||||
if (!user) return <PageLayout title="👤 Профиль"><div className="text-center py-8 text-gray-400">⏳</div></PageLayout>;
|
||||
|
||||
return (
|
||||
<PageLayout title="👤 Профиль" subtitle="Информация о пользователе">
|
||||
<div className="max-w-lg space-y-4">
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center text-2xl font-bold text-blue-600">
|
||||
{(user.name || user.full_name || '?')[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold">{user.name || user.full_name || 'Пользователь'}</div>
|
||||
<div className="text-sm text-gray-500">{user.email || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between py-1 border-b border-gray-50">
|
||||
<span className="text-gray-500">Роль</span>
|
||||
<span className="font-medium">{(user.roles || ['user']).join(', ')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1 border-b border-gray-50">
|
||||
<span className="text-gray-500">ID</span>
|
||||
<span className="font-mono text-xs text-gray-400">{user.sub || user.id || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-2">🔗 Быстрые ссылки</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<a href="/settings" className="block text-blue-500 hover:underline">⚙️ Настройки уведомлений</a>
|
||||
<a href="/audit-history" className="block text-blue-500 hover:underline">📝 История действий</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui';
|
||||
import { risksApi } from '@/lib/api/api-client';
|
||||
|
||||
export default function RisksPage() {
|
||||
const [risks, setRisks] = useState([] as any[]);
|
||||
@ -8,8 +9,7 @@ export default function RisksPage() {
|
||||
const [filter, setFilter] = useState('');
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const url = '/api/v1/risk-alerts' + (filter ? '?severity=' + filter : '');
|
||||
fetch(url).then(r => r.json()).then(d => { setRisks(d.items || []); });
|
||||
risksApi.list(filter ? { severity: filter } : {}).then(d => { setRisks(d.items || []); setLoading(false); }).catch(() => setLoading(false));
|
||||
}, [filter]);
|
||||
return (
|
||||
<>
|
||||
|
||||
84
app/settings/page.tsx
Normal file
84
app/settings/page.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageLayout } from '@/components/ui';
|
||||
import { apiFetch } from '@/lib/api/api-client';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [prefs, setPrefs] = useState<any>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch('/notification-preferences').then(setPrefs);
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
await apiFetch('/notification-preferences', { method: 'PUT', body: JSON.stringify(prefs) });
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const Toggle = ({ label, field }: { label: string; field: string }) => (
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-50">
|
||||
<span className="text-sm">{label}</span>
|
||||
<button onClick={() => setPrefs((p: any) => ({ ...p, [field]: !p[field] }))}
|
||||
className={`w-10 h-5 rounded-full transition-colors ${prefs?.[field] ? 'bg-blue-500' : 'bg-gray-300'}`}>
|
||||
<div className={`w-4 h-4 bg-white rounded-full shadow transition-transform ${prefs?.[field] ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!prefs) return <PageLayout title="⚙️ Настройки"><div className="text-center py-8 text-gray-400">⏳</div></PageLayout>;
|
||||
|
||||
return (
|
||||
<PageLayout title="⚙️ Настройки" subtitle="Уведомления и персонализация">
|
||||
<div className="max-w-lg space-y-6">
|
||||
<section className="card p-4">
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">📢 Типы уведомлений</h3>
|
||||
<Toggle label="⚠️ Обязательные ДЛГ (mandatory AD)" field="ad_mandatory" />
|
||||
<Toggle label="📋 Рекомендательные ДЛГ" field="ad_recommended" />
|
||||
<Toggle label="🔴 Критические дефекты" field="defect_critical" />
|
||||
<Toggle label="🟡 Значительные дефекты" field="defect_major" />
|
||||
<Toggle label="🟢 Незначительные дефекты" field="defect_minor" />
|
||||
<Toggle label="🔴 AOG наряды" field="wo_aog" />
|
||||
<Toggle label="✅ Закрытие нарядов (CRS)" field="wo_closed" />
|
||||
<Toggle label="⏱️ Критические ресурсы" field="life_limit_critical" />
|
||||
<Toggle label="🎓 Просрочка квалификации" field="personnel_expiry" />
|
||||
</section>
|
||||
|
||||
<section className="card p-4">
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">📡 Каналы доставки</h3>
|
||||
<Toggle label="📧 Email" field="channels_email" />
|
||||
<Toggle label="🔔 Push-уведомления" field="channels_push" />
|
||||
<Toggle label="⚡ WebSocket (real-time)" field="channels_ws" />
|
||||
</section>
|
||||
|
||||
|
||||
<section className="card p-4">
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">🎨 Оформление</h3>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm">🌙 Тёмная тема</span>
|
||||
<button id="dark-mode"
|
||||
onClick={() => {
|
||||
document.documentElement.classList.toggle('dark');
|
||||
localStorage.setItem('theme', document.documentElement.classList.contains('dark') ? 'dark' : 'light');
|
||||
}}
|
||||
className="w-10 h-5 rounded-full bg-gray-300 dark:bg-blue-500 transition-colors">
|
||||
<div className="w-4 h-4 bg-white rounded-full shadow transition-transform dark:translate-x-5 translate-x-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-t border-gray-50">
|
||||
<span className="text-sm">📏 Компактный режим</span>
|
||||
<button className="w-10 h-5 rounded-full bg-gray-300 transition-colors">
|
||||
<div className="w-4 h-4 bg-white rounded-full shadow translate-x-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button onClick={save} disabled={saving}
|
||||
className="btn-primary px-6 py-2 rounded text-sm disabled:opacity-50">
|
||||
{saving ? '⏳ Сохранение...' : '💾 Сохранить настройки'}
|
||||
</button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
43
backend/.env.example
Normal file
43
backend/.env.example
Normal file
@ -0,0 +1,43 @@
|
||||
# ===========================================
|
||||
# KLG ASUTK Backend — шаблон переменных окружения
|
||||
# Скопируйте в .env в каталоге backend и заполните. НЕ коммитьте реальные значения.
|
||||
# ===========================================
|
||||
|
||||
# API
|
||||
API_V1_PREFIX=/api/v1
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8000,http://localhost:8080
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://klg:klg@localhost:5432/klg
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# MinIO (S3-compatible)
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=klg-attachments
|
||||
MINIO_SECURE=false
|
||||
|
||||
# Auth (production: ENABLE_DEV_AUTH=false)
|
||||
ENABLE_DEV_AUTH=false
|
||||
DEV_TOKEN=dev
|
||||
OIDC_ISSUER=http://localhost:8180/realms/klg
|
||||
OIDC_JWKS_URL=
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_PER_MINUTE=60
|
||||
|
||||
# Inbox
|
||||
INBOX_DATA_DIR=./data
|
||||
INBOX_UPLOAD_MAX_MB=50
|
||||
|
||||
# Multi-tenancy
|
||||
ENABLE_RLS=true
|
||||
|
||||
# Optional: streaming
|
||||
ENABLE_RISINGWAVE=false
|
||||
ENABLE_REDPANDA=false
|
||||
REDPANDA_BROKERS=localhost:19092
|
||||
RISINGWAVE_URL=postgresql://root:risingwave@localhost:4566/dev
|
||||
@ -1,24 +1,16 @@
|
||||
"""
|
||||
FastAPI dependencies — auth, DB, roles.
|
||||
Supports both DEV mode and Keycloak OIDC.
|
||||
Единая точка импорта get_db для всех роутов и тестов.
|
||||
"""
|
||||
import os
|
||||
from fastapi import Depends, HTTPException, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.db.session import get_db
|
||||
from app.core.config import settings
|
||||
from app.api.oidc import verify_oidc_token, extract_user_from_claims
|
||||
|
||||
ENABLE_DEV_AUTH = os.getenv("ENABLE_DEV_AUTH", "true").lower() == "true"
|
||||
DEV_TOKEN = os.getenv("DEV_TOKEN", "dev")
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
ENABLE_DEV_AUTH = settings.ENABLE_DEV_AUTH
|
||||
DEV_TOKEN = settings.DEV_TOKEN
|
||||
|
||||
|
||||
# Dev user fallback
|
||||
|
||||
@ -8,7 +8,7 @@ from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import Aircraft, AircraftType
|
||||
from app.models.organization import Organization
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import audit, diff_changes, check_aircraft_access, filter_by_org, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import AirworthinessCertificate, AircraftHistory, Aircraft
|
||||
from app.schemas.airworthiness import (
|
||||
AirworthinessCertificateCreate, AirworthinessCertificateOut, AirworthinessCertificateUpdate,
|
||||
|
||||
@ -5,7 +5,7 @@ import os
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.helpers import audit
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import Attachment
|
||||
from app.schemas.attachment import AttachmentOut
|
||||
from app.services.storage import save_upload
|
||||
|
||||
@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import is_authority, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
router = APIRouter(tags=["audit"])
|
||||
|
||||
@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.services.email_service import email_service
|
||||
from app.api.helpers import audit, is_authority, get_org_name, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.integration.piv import push_event
|
||||
from app.models import CertApplication, ApplicationRemark, CertApplicationStatus
|
||||
from app.models.organization import Organization
|
||||
|
||||
@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import audit as audit_log, filter_by_org, paginate_query, check_aircraft_access
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import Audit, AuditResponse, Finding, ChecklistTemplate, ChecklistItem, Aircraft
|
||||
from app.schemas.audit import AuditCreate, AuditOut, AuditResponseCreate, AuditResponseOut, FindingOut
|
||||
from app.services.ws_manager import ws_manager, make_notification
|
||||
|
||||
@ -5,7 +5,7 @@ import csv, io
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import audit, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import ChecklistTemplate, ChecklistItem
|
||||
from app.schemas.audit import ChecklistTemplateCreate, ChecklistTemplateOut, ChecklistItemCreate, ChecklistItemOut
|
||||
|
||||
|
||||
@ -63,22 +63,24 @@ async def export_openapi():
|
||||
def detailed_health():
|
||||
"""Расширенная проверка всех компонентов системы."""
|
||||
import time
|
||||
from app.db.session import SessionLocal
|
||||
from app.core.config import settings
|
||||
|
||||
checks = {}
|
||||
|
||||
# Database
|
||||
try:
|
||||
from app.db.session import SessionLocal
|
||||
db = SessionLocal()
|
||||
db.execute("SELECT 1")
|
||||
db.execute(text("SELECT 1"))
|
||||
db.close()
|
||||
checks["database"] = {"status": "ok", "type": "PostgreSQL"}
|
||||
except Exception as e:
|
||||
checks["database"] = {"status": "error", "error": str(e)[:100]}
|
||||
|
||||
# Redis
|
||||
# Redis (from config)
|
||||
try:
|
||||
import redis
|
||||
r = redis.Redis(host="localhost", port=6379, socket_timeout=2)
|
||||
r = redis.from_url(settings.REDIS_URL, socket_timeout=2)
|
||||
r.ping()
|
||||
checks["redis"] = {"status": "ok"}
|
||||
except Exception:
|
||||
|
||||
@ -16,7 +16,7 @@ from fastapi.responses import FileResponse
|
||||
from app.core.config import settings
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.helpers import audit as audit_log
|
||||
from app.db.session import get_db as get_pg_db
|
||||
from app.api.deps import get_db as get_pg_db
|
||||
|
||||
router = APIRouter(prefix="/inbox", tags=["inbox"])
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import audit, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import IngestJobLog, MaintenanceTask, DefectReport, LimitedLifeComponent, LandingGearComponent, ChecklistItem, ChecklistTemplate, Aircraft
|
||||
|
||||
router = APIRouter(tags=["ingest"])
|
||||
|
||||
@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.helpers import paginate_query
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import LegalDocument, CrossReference, LegalComment, JudicialPractice
|
||||
from app.schemas.legal import (
|
||||
JurisdictionCreate,
|
||||
|
||||
@ -9,7 +9,7 @@ from app.api.helpers import audit, paginate_query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import Jurisdiction, LegalDocument, CrossReference, LegalComment, JudicialPractice
|
||||
from app.schemas.legal import (
|
||||
JurisdictionCreate,
|
||||
|
||||
@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import audit, diff_changes, check_aircraft_access, filter_by_org, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import AircraftModification, Aircraft
|
||||
from app.schemas.modifications import AircraftModificationCreate, AircraftModificationOut, AircraftModificationUpdate
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.helpers import paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import Notification
|
||||
from app.schemas.notification import NotificationOut
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import audit, diff_changes, is_authority, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import Organization, User, Aircraft, CertApplication
|
||||
from app.schemas.organization import OrganizationCreate, OrganizationOut, OrganizationUpdate
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.services.email_service import email_service
|
||||
from app.api.helpers import audit, filter_by_org, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import RiskAlert, Aircraft
|
||||
from app.schemas.risk_alert import RiskAlertOut
|
||||
from app.services.risk_scanner import scan_risks
|
||||
|
||||
@ -5,7 +5,7 @@ from sqlalchemy import func
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.helpers import is_operator, is_authority
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models import Aircraft, RiskAlert, Organization, Audit
|
||||
|
||||
router = APIRouter(tags=["stats"])
|
||||
|
||||
@ -5,7 +5,7 @@ from typing import List
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.helpers import is_authority
|
||||
from app.models.cert_application import CertApplication
|
||||
|
||||
@ -6,7 +6,7 @@ from datetime import datetime
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import get_org_name, is_authority, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.common import _coerce_datetime
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ Database session management.
|
||||
Sync engine for Alembic migrations + async-compatible session for routes.
|
||||
Production: use connection pool with proper limits.
|
||||
"""
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
|
||||
from app.core.config import settings
|
||||
@ -45,13 +45,9 @@ def _reset_tenant(dbapi_conn, connection_record, connection_proxy):
|
||||
|
||||
|
||||
def set_tenant(db: Session, org_id: str | None):
|
||||
"""Set the current tenant for RLS policies."""
|
||||
if org_id and not _is_sqlite:
|
||||
db.execute(
|
||||
__import__("sqlalchemy").text(
|
||||
f"SET LOCAL app.current_org_id = '{org_id}'"
|
||||
)
|
||||
)
|
||||
"""Set the current tenant for RLS policies. Uses bound parameter to avoid SQL injection."""
|
||||
if org_id is not None and not _is_sqlite:
|
||||
db.execute(text("SET LOCAL app.current_org_id = :org_id").bindparams(org_id=org_id or ""))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -151,14 +151,24 @@ app.add_middleware(RateLimitMiddleware)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global exception handler
|
||||
# Exception handlers (specific first, then generic)
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"detail": "Internal server error", "type": type(exc).__name__},
|
||||
)
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
|
||||
from app.api.exceptions import (
|
||||
validation_exception_handler,
|
||||
pydantic_validation_error_handler,
|
||||
integrity_error_handler,
|
||||
sqlalchemy_error_handler,
|
||||
general_exception_handler,
|
||||
)
|
||||
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(ValidationError, pydantic_validation_error_handler)
|
||||
app.add_exception_handler(IntegrityError, integrity_error_handler)
|
||||
app.add_exception_handler(SQLAlchemyError, sqlalchemy_error_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
39
backend/app/schemas/tasks_route_backup.py
Normal file
39
backend/app/schemas/tasks_route_backup.py
Normal file
@ -0,0 +1,39 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.core.auth import get_current_user
|
||||
from app.models.cert_application import CertApplication
|
||||
from app.schemas.tasks import TaskOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=List[TaskOut])
|
||||
def list_tasks(
|
||||
state: str = Query(default="open"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
q = db.query(CertApplication)
|
||||
|
||||
if state == "open":
|
||||
q = q.filter(CertApplication.status.in_(["submitted", "under_review", "remarks"]))
|
||||
|
||||
apps = q.order_by(CertApplication.updated_at.desc()).all()
|
||||
|
||||
return [
|
||||
TaskOut(
|
||||
entity_type="cert_application",
|
||||
entity_id=a.id,
|
||||
title=f"Заявка №{a.number}",
|
||||
status=a.status,
|
||||
due_at=a.remarks_deadline_at,
|
||||
priority="high" if a.remarks_deadline_at and a.remarks_deadline_at <= datetime.utcnow() else "normal",
|
||||
updated_at=a.updated_at,
|
||||
)
|
||||
for a in apps
|
||||
]
|
||||
61
backend/tests/conftest.py
Normal file
61
backend/tests/conftest.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""
|
||||
Test configuration for КЛГ АСУ ТК backend.
|
||||
Uses SQLite in-memory for fast isolated tests.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Force test config BEFORE importing app modules
|
||||
os.environ["DATABASE_URL"] = "sqlite:///test.db"
|
||||
os.environ["ENABLE_DEV_AUTH"] = "true"
|
||||
os.environ["DEV_TOKEN"] = "test"
|
||||
|
||||
from app.db.base import Base
|
||||
from app.api.deps import get_db
|
||||
from app.main import app
|
||||
from app.models import * # noqa: ensure all models registered
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
engine = create_engine("sqlite:///test.db", connect_args={"check_same_thread": False})
|
||||
TestSession = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Create all tables before each test, drop after."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
session = TestSession()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db):
|
||||
"""FastAPI test client with overridden DB dependency."""
|
||||
def _override_get_db():
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
pass
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers():
|
||||
"""Dev auth headers."""
|
||||
return {"Authorization": "Bearer test"}
|
||||
@ -68,11 +68,9 @@ export default function KnowledgeGraphVisualization({
|
||||
// Если не получилось через ESM, пробуем через require (для SSR)
|
||||
if (!Network || !DataSet) {
|
||||
if (typeof window === 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const visNetworkReq = require('vis-network');
|
||||
Network = visNetworkReq.Network || visNetworkReq.default?.Network || visNetworkReq;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const visDataReq = require('vis-data');
|
||||
DataSet = visDataReq.DataSet || visDataReq.default?.DataSet || visDataReq;
|
||||
}
|
||||
|
||||
208
docs/ANALYSIS_AND_RECOMMENDATIONS.md
Normal file
208
docs/ANALYSIS_AND_RECOMMENDATIONS.md
Normal file
@ -0,0 +1,208 @@
|
||||
# Анализ проекта КЛГ АСУ ТК и рекомендации
|
||||
|
||||
**Дата анализа:** 2025
|
||||
**Путь к проекту:** `/Users/yrippertgmail.com/Desktop/klg_asutk_app`
|
||||
|
||||
**Статус:** Рекомендации из данного документа применены (коммит/дата по желанию).
|
||||
|
||||
---
|
||||
|
||||
## 1. Обзор архитектуры
|
||||
|
||||
| Слой | Технологии |
|
||||
|------|------------|
|
||||
| **Frontend** | Next.js 14, React 18, Tailwind, SWR, next-auth (beta), PWA |
|
||||
| **Backend** | FastAPI, SQLAlchemy 2, Pydantic 2, Alembic, Redis, Prometheus |
|
||||
| **Инфраструктура** | PostgreSQL, Redis, Keycloak OIDC, Docker, Helm |
|
||||
|
||||
- **Фронт:** ~40 страниц, единый API-клиент (`lib/api/api-client.ts`), проксирование `/api/v1/*` на бэкенд через `next.config.js` rewrites.
|
||||
- **Бэкенд:** 29+ роут-файлов, 147+ эндпоинтов, мультитенантность (RLS), роли (RBAC), DEV/OIDC авторизация.
|
||||
|
||||
---
|
||||
|
||||
## 2. Сильные стороны
|
||||
|
||||
- **Чёткая доменная модель:** ВС, ЛГ, наряды, дефекты, персонал ПЛГ, ФГИС РЭВС, нормативная база — с привязкой к правовым основаниям (ВК РФ, ФАП, EASA, ICAO).
|
||||
- **Единый API-клиент:** `api-client.ts` с токеном, редиректом при 401, типами; часть страниц уже переведена на него.
|
||||
- **Безопасность:** `.env` в `.gitignore`, `.env.example`, `docs/SECURITY.md`, заголовки X-Frame-Options / X-Content-Type-Options, CORS из настроек.
|
||||
- **Логгер:** `lib/logger.ts` вместо `console.log` на фронте; на бэкенде — `logging`.
|
||||
- **Рефакторинг модулей:** legal → пакет (base + handlers), personnel — пакет, FGIS — вынесены типы в `fgis/base_service.py`.
|
||||
- **Тесты:** pytest в backend (conftest, переопределение БД), Playwright для E2E.
|
||||
|
||||
---
|
||||
|
||||
## 3. Критичные проблемы и рекомендации
|
||||
|
||||
### 3.1 SQL-инъекция в `set_tenant` (backend)
|
||||
|
||||
**Файл:** `backend/app/db/session.py`
|
||||
|
||||
```python
|
||||
db.execute(text(f"SET LOCAL app.current_org_id = '{org_id}'"))
|
||||
```
|
||||
|
||||
Если `org_id` приходит из JWT/пользователя, возможна инъекция.
|
||||
|
||||
**Рекомендация:** использовать параметризованный запрос (например, через `text("... :org_id").bindparams(org_id=org_id)` или аналог для вашего драйвера), либо жёстко валидировать `org_id` (UUID/белый список).
|
||||
|
||||
---
|
||||
|
||||
### 3.2 SQLAlchemy 2: `db.execute("SELECT 1")` без `text()`
|
||||
|
||||
**Файл:** `backend/app/api/routes/health.py`, эндпоинт `detailed_health`:
|
||||
|
||||
```python
|
||||
db.execute("SELECT 1")
|
||||
```
|
||||
|
||||
В SQLAlchemy 2.x строка должна быть обёрнута в `text()`.
|
||||
|
||||
**Рекомендация:**
|
||||
|
||||
```python
|
||||
from sqlalchemy import text
|
||||
db.execute(text("SELECT 1"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Тесты: переопределение `get_db` не покрывает все роуты
|
||||
|
||||
**Файл:** `backend/tests/conftest.py`
|
||||
|
||||
Переопределяется `app.db.session.get_db`, тогда как часть роутов импортирует `get_db` из `app.api.deps`. В таких роутах подмена не срабатывает, тесты могут ходить в реальную БД.
|
||||
|
||||
**Рекомендация:** везде использовать один источник зависимости — например, только `app.api.deps.get_db` — и в conftest переопределять именно его:
|
||||
|
||||
```python
|
||||
from app.api.deps import get_db
|
||||
# ...
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
```
|
||||
|
||||
Либо оставить реализацию в `session.py`, а в `deps.py` реэкспортировать `from app.db.session import get_db` и везде импортировать из `deps`.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Обработчики исключений не подключены
|
||||
|
||||
**Файл:** `backend/app/main.py`
|
||||
|
||||
В `app/api/exceptions.py` определены обработчики для `RequestValidationError`, `IntegrityError`, `SQLAlchemyError` и др., но в `main.py` они не регистрируются. Используется только общий `@app.exception_handler(Exception)`.
|
||||
|
||||
**Рекомендация:** в `main.py` подключить обработчики из `exceptions.py` (например, через `app.add_exception_handler`), чтобы возвращать 422/409 с единообразным форматом и не «проглатывать» валидацию и ошибки БД общим 500.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Сборка и качество кода отключены
|
||||
|
||||
**Файл:** `next.config.js`
|
||||
|
||||
```javascript
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
```
|
||||
|
||||
Линт и проверка типов при сборке отключены — ошибки ESLint/TypeScript не блокируют деплой.
|
||||
|
||||
**Рекомендация:** для продакшена включить проверки: убрать или выставить `false` и починить накопившиеся ошибки; при необходимости временно ограничить scope (например, только `app/`, `lib/`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Важные улучшения (средний приоритет)
|
||||
|
||||
### 4.1 Единый источник зависимости `get_db`
|
||||
|
||||
Сейчас часть роутов импортирует `get_db` из `app.db.session`, часть — из `app.api.deps`. Это усложняет тесты и конфигурацию.
|
||||
|
||||
**Рекомендация:** оставить реализацию в `app.db.session`, в `app.api.deps` делать реэкспорт и везде в роутах использовать `from app.api.deps import get_db`. В тестах переопределять только этот `get_db`.
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Единообразные запросы к API на фронте
|
||||
|
||||
Много страниц по-прежнему используют «голый» `fetch('/api/v1/...')` без единого клиента: разный подход к токену, ошибкам и редиректу при 401.
|
||||
|
||||
**Рекомендация:** постепенно переводить все вызовы на `lib/api/api-client.ts` (или обёртки над ним), чтобы авторизация, обработка ошибок и редирект были централизованы.
|
||||
|
||||
---
|
||||
|
||||
### 4.3 DEV-авторизация по умолчанию
|
||||
|
||||
**Файл:** `backend/app/api/deps.py`
|
||||
|
||||
```python
|
||||
ENABLE_DEV_AUTH = os.getenv("ENABLE_DEV_AUTH", "true").lower() == "true"
|
||||
```
|
||||
|
||||
При отсутствии переменной окружения включён dev-режим (обход авторизации).
|
||||
|
||||
**Рекомендация:** по умолчанию считать продакшен: `"false"` и явно включать dev только в локальной среде (например, в `.env.local` с `ENABLE_DEV_AUTH=true`). В `config` уже есть `ENABLE_DEV_AUTH: bool = False` — использовать `settings.ENABLE_DEV_AUTH` вместо чтения `os.getenv` в deps.
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Health check: Redis и зависимости
|
||||
|
||||
**Файл:** `backend/app/api/routes/health.py`
|
||||
|
||||
В `detailed_health` Redis подключается к `host="localhost", port=6379`, тогда как в конфиге используется `REDIS_URL`. При смене окружения проверка может быть некорректной.
|
||||
|
||||
**Рекомендация:** брать URL из `settings.REDIS_URL` (или из переменной окружения) и использовать `redis.from_url()` как в основном `health_check`.
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Дублирование логгеров на фронте
|
||||
|
||||
В `package.json` присутствуют и `winston`, и `pino`. При этом в коде используется собственный `lib/logger.ts`.
|
||||
|
||||
**Рекомендация:** если winston/pino не используются — удалить из зависимостей; если планируется использование — зафиксировать один стек и постепенно перейти на него в `lib/logger.ts`, чтобы не дублировать логику.
|
||||
|
||||
---
|
||||
|
||||
## 5. Дополнительные рекомендации (низкий приоритет)
|
||||
|
||||
### 5.1 Backend `.env.example`
|
||||
|
||||
В корне есть `.env.example` для фронта и общих переменных; для бэкенда отдельного шаблона нет.
|
||||
|
||||
**Рекомендация:** добавить `backend/.env.example` с переменными из `app/core/config.py` (DATABASE_URL, REDIS_URL, OIDC_*, ENABLE_DEV_AUTH, CORS_ORIGINS и т.д.) и кратким комментарием.
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Миграции при старте
|
||||
|
||||
В `main.py` при старте выполняется обход папки `migrations` и выполнение всех `.sql` файлов. При сбое делается только `rollback`, без явного учёта уже применённых миграций.
|
||||
|
||||
**Рекомендация:** в продакшене полагаться на Alembic и не применять сырые SQL при старте приложения; оставить автозапуск миграций только для dev или вынести в отдельный job/entrypoint.
|
||||
|
||||
---
|
||||
|
||||
### 5.3 Документация API и версии
|
||||
|
||||
В коде указаны версии (например, `2.1.0` в main, `2.2.0` в health). Имеет смысл вынести версию в одно место (например, `app/__init__.py` или `pyproject.toml`) и использовать её в OpenAPI и в health.
|
||||
|
||||
---
|
||||
|
||||
### 5.4 Роли на эндпоинтах
|
||||
|
||||
Часть роутов не использует `require_roles` и полагается только на `get_current_user`. Для чувствительных операций (удаление, экспорт, панель регулятора) стоит явно ограничивать доступ по ролям.
|
||||
|
||||
---
|
||||
|
||||
## 6. Краткий чек-лист по приоритетам
|
||||
|
||||
| Приоритет | Действие |
|
||||
|-----------|----------|
|
||||
| Критично | Исправить `set_tenant` (параметризованный запрос или валидация `org_id`) |
|
||||
| Критично | В `health.py` использовать `text("SELECT 1")` |
|
||||
| Критично | Унифицировать `get_db` и переопределение в тестах |
|
||||
| Критично | Подключить обработчики из `exceptions.py` в `main.py` |
|
||||
| Высоко | Убрать `ignoreDuringBuilds` / `ignoreBuildErrors` в next.config и починить ошибки |
|
||||
| Средне | Перевести все вызовы API на фронте на api-client |
|
||||
| Средне | Использовать в deps `settings.ENABLE_DEV_AUTH`, по умолчанию False |
|
||||
| Средне | В detailed_health использовать `REDIS_URL` из конфига |
|
||||
| Низко | Добавить backend/.env.example, упорядочить версии и миграции |
|
||||
|
||||
---
|
||||
|
||||
© Анализ подготовлен для проекта КЛГ АСУ ТК (АО «REFLY»).
|
||||
230
lib/api/api-client.ts
Normal file
230
lib/api/api-client.ts
Normal file
@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Unified API client for КЛГ АСУ ТК.
|
||||
* Single source of truth: all requests go to FastAPI backend.
|
||||
* Replaces: backend-client.ts + cached-api.ts + Next.js API routes.
|
||||
*
|
||||
* Разработчик: АО «REFLY»
|
||||
*/
|
||||
|
||||
// In production, NEXT_PUBLIC_API_URL points to FastAPI.
|
||||
// In development, Next.js proxies via rewrites in next.config.
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '/api/v1';
|
||||
|
||||
// ─── Auth ────────────────────────────────────────
|
||||
let _token: string | null = null;
|
||||
|
||||
export function setAuthToken(token: string) {
|
||||
_token = token;
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('klg_token', token);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
if (_token) return _token;
|
||||
if (typeof window !== 'undefined') {
|
||||
_token = sessionStorage.getItem('klg_token');
|
||||
}
|
||||
// Fallback to dev token
|
||||
return _token || process.env.NEXT_PUBLIC_DEV_TOKEN || null;
|
||||
}
|
||||
|
||||
export function clearAuthToken() {
|
||||
_token = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('klg_token');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Base fetch ──────────────────────────────────
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string, public body?: any) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Unified fetch with auth and 401 handling. Use for any backend path (e.g. /stats, /airworthiness-core/directives). */
|
||||
export async function apiFetch<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(opts.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
const url = path.startsWith('http') ? path : `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
const res = await fetch(url, { ...opts, headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
clearAuthToken();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new ApiError(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new ApiError(res.status, body?.detail || `HTTP ${res.status}`, body);
|
||||
}
|
||||
|
||||
if (res.status === 204) return null as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Paginated response type ─────────────────────
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
interface QueryParams {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
q?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function buildQuery(params?: QueryParams): string {
|
||||
if (!params) return '';
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') sp.set(k, String(v));
|
||||
}
|
||||
const qs = sp.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
// ─── Resource APIs ───────────────────────────────
|
||||
|
||||
// Stats
|
||||
export const statsApi = {
|
||||
get: () => apiFetch('/stats'),
|
||||
};
|
||||
|
||||
// Organizations
|
||||
export const organizationsApi = {
|
||||
list: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/organizations${buildQuery(params)}`),
|
||||
get: (id: string) => apiFetch(`/organizations/${id}`),
|
||||
create: (data: any) => apiFetch('/organizations', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: any) => apiFetch(`/organizations/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => apiFetch(`/organizations/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// Aircraft
|
||||
export const aircraftApi = {
|
||||
list: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/aircraft${buildQuery(params)}`),
|
||||
get: (id: string) => apiFetch(`/aircraft/${id}`),
|
||||
create: (data: any) => apiFetch('/aircraft', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: any) => apiFetch(`/aircraft/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => apiFetch(`/aircraft/${id}`, { method: 'DELETE' }),
|
||||
types: () => apiFetch<any[]>('/aircraft/types'),
|
||||
};
|
||||
|
||||
// Cert Applications
|
||||
export const applicationsApi = {
|
||||
list: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/cert-applications${buildQuery(params)}`),
|
||||
get: (id: string) => apiFetch(`/cert-applications/${id}`),
|
||||
create: (data: any) => apiFetch('/cert-applications', { method: 'POST', body: JSON.stringify(data) }),
|
||||
submit: (id: string) => apiFetch(`/cert-applications/${id}/submit`, { method: 'POST' }),
|
||||
startReview: (id: string) => apiFetch(`/cert-applications/${id}/start-review`, { method: 'POST' }),
|
||||
approve: (id: string) => apiFetch(`/cert-applications/${id}/approve`, { method: 'POST' }),
|
||||
reject: (id: string) => apiFetch(`/cert-applications/${id}/reject`, { method: 'POST' }),
|
||||
addRemark: (id: string, data: any) => apiFetch(`/cert-applications/${id}/remarks`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
listRemarks: (id: string) => apiFetch<any[]>(`/cert-applications/${id}/remarks`),
|
||||
};
|
||||
|
||||
// Airworthiness
|
||||
export const airworthinessApi = {
|
||||
listCertificates: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/airworthiness/certificates${buildQuery(params)}`),
|
||||
getCertificate: (id: string) => apiFetch(`/airworthiness/certificates/${id}`),
|
||||
createCertificate: (data: any) => apiFetch('/airworthiness/certificates', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateCertificate: (id: string, data: any) => apiFetch(`/airworthiness/certificates/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
getHistory: (aircraftId: string, params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/aircraft/${aircraftId}/history${buildQuery(params)}`),
|
||||
};
|
||||
|
||||
// Modifications
|
||||
export const modificationsApi = {
|
||||
list: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/modifications${buildQuery(params)}`),
|
||||
listForAircraft: (aircraftId: string, params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/aircraft/${aircraftId}/modifications${buildQuery(params)}`),
|
||||
get: (id: string) => apiFetch(`/modifications/${id}`),
|
||||
create: (aircraftId: string, data: any) => apiFetch(`/aircraft/${aircraftId}/modifications`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: any) => apiFetch(`/modifications/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
};
|
||||
|
||||
// Risk Alerts
|
||||
export const risksApi = {
|
||||
list: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/risk-alerts${buildQuery(params)}`),
|
||||
scan: () => apiFetch('/risk-alerts/scan', { method: 'POST' }),
|
||||
resolve: (id: string) => apiFetch(`/risk-alerts/${id}/resolve`, { method: 'PATCH' }),
|
||||
};
|
||||
|
||||
// Audits
|
||||
export const auditsApi = {
|
||||
list: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/audits${buildQuery(params)}`),
|
||||
get: (id: string) => apiFetch(`/audits/${id}`),
|
||||
create: (data: any) => apiFetch('/audits', { method: 'POST', body: JSON.stringify(data) }),
|
||||
complete: (id: string) => apiFetch(`/audits/${id}/complete`, { method: 'PATCH' }),
|
||||
submitResponse: (auditId: string, data: any) => apiFetch(`/audits/${auditId}/responses`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
listResponses: (auditId: string) => apiFetch<any[]>(`/audits/${auditId}/responses`),
|
||||
listFindings: (auditId: string) => apiFetch<any[]>(`/audits/${auditId}/findings`),
|
||||
};
|
||||
|
||||
// Checklists
|
||||
export const checklistsApi = {
|
||||
listTemplates: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/checklists/templates${buildQuery(params)}`),
|
||||
getTemplate: (id: string) => apiFetch(`/checklists/templates/${id}`),
|
||||
createTemplate: (data: any) => apiFetch('/checklists/templates', { method: 'POST', body: JSON.stringify(data) }),
|
||||
generate: (source: string, name: string, items?: any[]) => apiFetch(`/checklists/generate?source=${source}&name=${name}`, { method: 'POST', body: items ? JSON.stringify(items) : undefined }),
|
||||
};
|
||||
|
||||
// Notifications
|
||||
export const notificationsApi = {
|
||||
list: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/notifications${buildQuery(params)}`),
|
||||
markRead: (id: string) => apiFetch(`/notifications/${id}/read`, { method: 'POST' }),
|
||||
markAllRead: () => apiFetch('/notifications/read-all', { method: 'POST' }),
|
||||
};
|
||||
|
||||
// Users
|
||||
export const usersApi = {
|
||||
list: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/users${buildQuery(params)}`),
|
||||
get: (id: string) => apiFetch(`/users/${id}`),
|
||||
me: () => apiFetch('/users/me'),
|
||||
};
|
||||
|
||||
// Tasks
|
||||
export const tasksApi = {
|
||||
list: (state?: string) => apiFetch<any[]>(`/tasks?state=${state || 'open'}`),
|
||||
};
|
||||
|
||||
// Audit Log
|
||||
export const auditLogApi = {
|
||||
list: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/audit/events${buildQuery(params)}`),
|
||||
};
|
||||
|
||||
// Attachments
|
||||
export const attachmentsApi = {
|
||||
upload: async (ownerKind: string, ownerId: string, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const token = getAuthToken();
|
||||
const res = await fetch(`${API_BASE}/attachments/${ownerKind}/${ownerId}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new ApiError(res.status, 'Upload failed');
|
||||
return res.json();
|
||||
},
|
||||
list: (ownerKind: string, ownerId: string) => apiFetch<any[]>(`/attachments/${ownerKind}/${ownerId}`),
|
||||
downloadUrl: (id: string) => `${API_BASE}/attachments/${id}/download`,
|
||||
};
|
||||
|
||||
// Health
|
||||
export const healthApi = {
|
||||
check: () => apiFetch('/health'),
|
||||
};
|
||||
@ -2,6 +2,7 @@
|
||||
* Единый логгер приложения. Используйте вместо console.log.
|
||||
* В production можно заменить вывод на отправку в систему мониторинга.
|
||||
*/
|
||||
/* eslint-disable no-console -- this file implements the logger using console */
|
||||
const isDev = typeof process !== "undefined" && process.env?.NODE_ENV === "development";
|
||||
|
||||
function noop(): void {}
|
||||
|
||||
110
lib/ws-client.ts
Normal file
110
lib/ws-client.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* WebSocket client for КЛГ АСУ ТК realtime notifications.
|
||||
* Auto-reconnect with exponential backoff.
|
||||
* Разработчик: АО «REFLY»
|
||||
*/
|
||||
|
||||
type NotificationHandler = (msg: WsNotification) => void;
|
||||
|
||||
export interface WsNotification {
|
||||
type: string;
|
||||
entity_type: string;
|
||||
entity_id?: string;
|
||||
timestamp: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const WS_BASE = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000/api/v1';
|
||||
|
||||
class WsClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private handlers: Set<NotificationHandler> = new Set();
|
||||
private userId: string = '';
|
||||
private orgId: string = '';
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnect = 10;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
connect(userId: string, orgId?: string) {
|
||||
if (typeof window === 'undefined') return; // SSR guard
|
||||
this.userId = userId;
|
||||
this.orgId = orgId || '';
|
||||
this._doConnect();
|
||||
}
|
||||
|
||||
private _doConnect() {
|
||||
try {
|
||||
const url = `${WS_BASE}/ws/notifications?user_id=${this.userId}&org_id=${this.orgId}`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
||||
// eslint-disable-next-line no-console -- dev-only WS connection log
|
||||
console.warn('[WS] Connected');
|
||||
}
|
||||
this.reconnectAttempts = 0;
|
||||
// Start ping every 30s
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send('ping');
|
||||
}
|
||||
}, 30_000);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
if (event.data === 'pong') return;
|
||||
try {
|
||||
const msg: WsNotification = JSON.parse(event.data);
|
||||
this.handlers.forEach(h => h(msg));
|
||||
} catch (e) {
|
||||
console.warn('[WS] Parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
if (process.env.NODE_ENV === 'development') console.warn('[WS] Disconnected');
|
||||
this._cleanup();
|
||||
this._scheduleReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
console.error('[WS] Error:', err);
|
||||
};
|
||||
} catch (e) {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private _cleanup() {
|
||||
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; }
|
||||
}
|
||||
|
||||
private _scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnect) return;
|
||||
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30_000);
|
||||
this.reconnectAttempts++;
|
||||
if (process.env.NODE_ENV === 'development') console.warn(`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||
this.reconnectTimer = setTimeout(() => this._doConnect(), delay);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
||||
this._cleanup();
|
||||
this.maxReconnect = 0; // prevent reconnect
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
onNotification(handler: NotificationHandler) {
|
||||
this.handlers.add(handler);
|
||||
return () => { this.handlers.delete(handler); };
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
export const wsClient = new WsClient();
|
||||
@ -1,7 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
eslint: { ignoreDuringBuilds: false },
|
||||
typescript: { ignoreBuildErrors: false },
|
||||
async rewrites() {
|
||||
// In development, proxy /api/v1/* to FastAPI backend
|
||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user