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:
Yuriy 2026-02-14 21:48:58 +03:00
parent d9dd6d66cd
commit a7da43be0e
37 changed files with 891 additions and 76 deletions

View File

@ -1,10 +1 @@
{ {"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/"]}
"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/"]
}

View File

@ -1,5 +1,6 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { logInfo } from '@/lib/logger';
// Эндпоинт для обновления нормативных документов // Эндпоинт для обновления нормативных документов
// Должен вызываться автоматически раз в месяц через cron job или scheduled task // Должен вызываться автоматически раз в месяц через cron job или scheduled task
@ -7,7 +8,7 @@ export async function POST(request: Request) {
try { try {
const { source } = await request.json().catch(() => ({})); const { source } = await request.json().catch(() => ({}));
console.log(`Начато обновление нормативных документов для источника: ${source || 'все'}`); logInfo(`Начато обновление нормативных документов для источника: ${source || 'все'}`);
// В реальном приложении здесь будет: // В реальном приложении здесь будет:
// 1. Загрузка документов с официальных сайтов (ICAO, EASA, FAA, МАК, АРМАК) // 1. Загрузка документов с официальных сайтов (ICAO, EASA, FAA, МАК, АРМАК)

View File

@ -6,6 +6,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { PageLayout, StatusBadge } from '@/components/ui'; import { PageLayout, StatusBadge } from '@/components/ui';
import Link from 'next/link'; import Link from 'next/link';
import { apiFetch } from '@/lib/api/api-client';
interface DashboardData { interface DashboardData {
overview: any; directives: any; lifeLimits: any; personnel: any; risks: any; overview: any; directives: any; lifeLimits: any; personnel: any; risks: any;
@ -36,14 +37,14 @@ export default function DashboardPage() {
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
fetch('/api/v1/stats').then(r => r.json()).catch(() => null), apiFetch('/stats').catch(() => null),
fetch('/api/v1/airworthiness-core/directives?status=open').then(r => r.json()).catch(() => ({ total: 0, items: [] })), apiFetch<{ total?: number; items?: unknown[] }>('/airworthiness-core/directives?status=open').catch(() => ({ total: 0, items: [] })),
fetch('/api/v1/airworthiness-core/life-limits').then(r => r.json()).catch(() => ({ total: 0, items: [] })), apiFetch<{ total?: number; items?: unknown[] }>('/airworthiness-core/life-limits').catch(() => ({ total: 0, items: [] })),
fetch('/api/v1/personnel-plg/compliance-report').then(r => r.json()).catch(() => null), apiFetch('/personnel-plg/compliance-report').catch(() => null),
fetch('/api/v1/risk-alerts').then(r => r.json()).catch(() => ({ total: 0 })), apiFetch<{ total?: number }>('/risk-alerts').catch(() => ({ total: 0 })),
fetch('/api/v1/work-orders/stats/summary').then(r => r.json()).catch(() => ({ total: 0, in_progress: 0, aog: 0 })), apiFetch<{ total?: number; in_progress?: number; aog?: number }>('/work-orders/stats/summary').catch(() => ({ total: 0, in_progress: 0, aog: 0 })),
fetch('/api/v1/defects/?status=open').then(r => r.json()).catch(() => ({ total: 0 })), apiFetch<{ total?: number }>('/defects/?status=open').catch(() => ({ total: 0 })),
fetch('/api/v1/fgis-revs/status').then(r => r.json()).catch(() => ({ connection_status: 'unknown' })), apiFetch<{ connection_status?: string }>('/fgis-revs/status').catch(() => ({ connection_status: 'unknown' })),
]).then(([overview, directives, lifeLimits, personnel, risks, woStats, openDefects, fgisStatus]) => { ]).then(([overview, directives, lifeLimits, personnel, risks, woStats, openDefects, fgisStatus]) => {
setData({ overview, directives, lifeLimits, personnel, risks, woStats, openDefects, fgisStatus }); setData({ overview, directives, lifeLimits, personnel, risks, woStats, openDefects, fgisStatus });
setLoading(false); setLoading(false);

48
app/profile/page.tsx Normal file
View 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>
);
}

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui'; import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui';
import { risksApi } from '@/lib/api/api-client';
export default function RisksPage() { export default function RisksPage() {
const [risks, setRisks] = useState([] as any[]); const [risks, setRisks] = useState([] as any[]);
@ -8,8 +9,7 @@ export default function RisksPage() {
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
const url = '/api/v1/risk-alerts' + (filter ? '?severity=' + filter : ''); risksApi.list(filter ? { severity: filter } : {}).then(d => { setRisks(d.items || []); setLoading(false); }).catch(() => setLoading(false));
fetch(url).then(r => r.json()).then(d => { setRisks(d.items || []); });
}, [filter]); }, [filter]);
return ( return (
<> <>

84
app/settings/page.tsx Normal file
View 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
View 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

View File

@ -1,24 +1,16 @@
""" """
FastAPI dependencies auth, DB, roles. FastAPI dependencies auth, DB, roles.
Supports both DEV mode and Keycloak OIDC. Supports both DEV mode and Keycloak OIDC.
Единая точка импорта get_db для всех роутов и тестов.
""" """
import os
from fastapi import Depends, HTTPException, Header 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 from app.api.oidc import verify_oidc_token, extract_user_from_claims
ENABLE_DEV_AUTH = os.getenv("ENABLE_DEV_AUTH", "true").lower() == "true" ENABLE_DEV_AUTH = settings.ENABLE_DEV_AUTH
DEV_TOKEN = os.getenv("DEV_TOKEN", "dev") DEV_TOKEN = settings.DEV_TOKEN
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Dev user fallback # Dev user fallback

View File

@ -8,7 +8,7 @@ from sqlalchemy import or_
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from app.api.deps import get_current_user, require_roles 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 import Aircraft, AircraftType
from app.models.organization import Organization from app.models.organization import Organization
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog

View File

@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user, require_roles 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.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.models import AirworthinessCertificate, AircraftHistory, Aircraft
from app.schemas.airworthiness import ( from app.schemas.airworthiness import (
AirworthinessCertificateCreate, AirworthinessCertificateOut, AirworthinessCertificateUpdate, AirworthinessCertificateCreate, AirworthinessCertificateOut, AirworthinessCertificateUpdate,

View File

@ -5,7 +5,7 @@ import os
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.api.helpers import audit 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.models import Attachment
from app.schemas.attachment import AttachmentOut from app.schemas.attachment import AttachmentOut
from app.services.storage import save_upload from app.services.storage import save_upload

View File

@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user, require_roles from app.api.deps import get_current_user, require_roles
from app.api.helpers import is_authority, paginate_query 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 from app.models.audit_log import AuditLog
router = APIRouter(tags=["audit"]) router = APIRouter(tags=["audit"])

View File

@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user, require_roles from app.api.deps import get_current_user, require_roles
from app.services.email_service import email_service from app.services.email_service import email_service
from app.api.helpers import audit, is_authority, get_org_name, paginate_query 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.integration.piv import push_event
from app.models import CertApplication, ApplicationRemark, CertApplicationStatus from app.models import CertApplication, ApplicationRemark, CertApplicationStatus
from app.models.organization import Organization from app.models.organization import Organization

View File

@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user, require_roles 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.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.models import Audit, AuditResponse, Finding, ChecklistTemplate, ChecklistItem, Aircraft
from app.schemas.audit import AuditCreate, AuditOut, AuditResponseCreate, AuditResponseOut, FindingOut from app.schemas.audit import AuditCreate, AuditOut, AuditResponseCreate, AuditResponseOut, FindingOut
from app.services.ws_manager import ws_manager, make_notification from app.services.ws_manager import ws_manager, make_notification

View File

@ -5,7 +5,7 @@ import csv, io
from app.api.deps import get_current_user, require_roles from app.api.deps import get_current_user, require_roles
from app.api.helpers import audit, paginate_query 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.models import ChecklistTemplate, ChecklistItem
from app.schemas.audit import ChecklistTemplateCreate, ChecklistTemplateOut, ChecklistItemCreate, ChecklistItemOut from app.schemas.audit import ChecklistTemplateCreate, ChecklistTemplateOut, ChecklistItemCreate, ChecklistItemOut

View File

@ -63,22 +63,24 @@ async def export_openapi():
def detailed_health(): def detailed_health():
"""Расширенная проверка всех компонентов системы.""" """Расширенная проверка всех компонентов системы."""
import time import time
from app.db.session import SessionLocal
from app.core.config import settings
checks = {} checks = {}
# Database # Database
try: try:
from app.db.session import SessionLocal
db = SessionLocal() db = SessionLocal()
db.execute("SELECT 1") db.execute(text("SELECT 1"))
db.close() db.close()
checks["database"] = {"status": "ok", "type": "PostgreSQL"} checks["database"] = {"status": "ok", "type": "PostgreSQL"}
except Exception as e: except Exception as e:
checks["database"] = {"status": "error", "error": str(e)[:100]} checks["database"] = {"status": "error", "error": str(e)[:100]}
# Redis # Redis (from config)
try: try:
import redis import redis
r = redis.Redis(host="localhost", port=6379, socket_timeout=2) r = redis.from_url(settings.REDIS_URL, socket_timeout=2)
r.ping() r.ping()
checks["redis"] = {"status": "ok"} checks["redis"] = {"status": "ok"}
except Exception: except Exception:

View File

@ -16,7 +16,7 @@ from fastapi.responses import FileResponse
from app.core.config import settings from app.core.config import settings
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.api.helpers import audit as audit_log 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"]) router = APIRouter(prefix="/inbox", tags=["inbox"])

View File

@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user, require_roles from app.api.deps import get_current_user, require_roles
from app.api.helpers import audit, paginate_query 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 from app.models import IngestJobLog, MaintenanceTask, DefectReport, LimitedLifeComponent, LandingGearComponent, ChecklistItem, ChecklistTemplate, Aircraft
router = APIRouter(tags=["ingest"]) router = APIRouter(tags=["ingest"])

View File

@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from app.api.helpers import paginate_query from app.api.helpers import paginate_query
from app.api.deps import get_current_user, require_roles 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.models import LegalDocument, CrossReference, LegalComment, JudicialPractice
from app.schemas.legal import ( from app.schemas.legal import (
JurisdictionCreate, JurisdictionCreate,

View File

@ -9,7 +9,7 @@ from app.api.helpers import audit, paginate_query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, require_roles 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.models import Jurisdiction, LegalDocument, CrossReference, LegalComment, JudicialPractice
from app.schemas.legal import ( from app.schemas.legal import (
JurisdictionCreate, JurisdictionCreate,

View File

@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user, require_roles 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.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.models import AircraftModification, Aircraft
from app.schemas.modifications import AircraftModificationCreate, AircraftModificationOut, AircraftModificationUpdate from app.schemas.modifications import AircraftModificationCreate, AircraftModificationOut, AircraftModificationUpdate

View File

@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.api.helpers import paginate_query 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.models import Notification
from app.schemas.notification import NotificationOut from app.schemas.notification import NotificationOut

View File

@ -6,7 +6,7 @@ from sqlalchemy.exc import IntegrityError
from app.api.deps import get_current_user, require_roles from app.api.deps import get_current_user, require_roles
from app.api.helpers import audit, diff_changes, is_authority, paginate_query 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.models import Organization, User, Aircraft, CertApplication
from app.schemas.organization import OrganizationCreate, OrganizationOut, OrganizationUpdate from app.schemas.organization import OrganizationCreate, OrganizationOut, OrganizationUpdate

View File

@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user, require_roles from app.api.deps import get_current_user, require_roles
from app.services.email_service import email_service from app.services.email_service import email_service
from app.api.helpers import audit, filter_by_org, paginate_query 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.models import RiskAlert, Aircraft
from app.schemas.risk_alert import RiskAlertOut from app.schemas.risk_alert import RiskAlertOut
from app.services.risk_scanner import scan_risks from app.services.risk_scanner import scan_risks

View File

@ -5,7 +5,7 @@ from sqlalchemy import func
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.api.helpers import is_operator, is_authority 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 from app.models import Aircraft, RiskAlert, Organization, Audit
router = APIRouter(tags=["stats"]) router = APIRouter(tags=["stats"])

View File

@ -5,7 +5,7 @@ from typing import List
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session 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.deps import get_current_user
from app.api.helpers import is_authority from app.api.helpers import is_authority
from app.models.cert_application import CertApplication from app.models.cert_application import CertApplication

View File

@ -6,7 +6,7 @@ from datetime import datetime
from app.api.deps import get_current_user, require_roles from app.api.deps import get_current_user, require_roles
from app.api.helpers import get_org_name, is_authority, paginate_query 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.models.user import User
from app.schemas.common import _coerce_datetime from app.schemas.common import _coerce_datetime

View File

@ -3,7 +3,7 @@ Database session management.
Sync engine for Alembic migrations + async-compatible session for routes. Sync engine for Alembic migrations + async-compatible session for routes.
Production: use connection pool with proper limits. 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 sqlalchemy.orm import sessionmaker, Session
from app.core.config import settings 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): def set_tenant(db: Session, org_id: str | None):
"""Set the current tenant for RLS policies.""" """Set the current tenant for RLS policies. Uses bound parameter to avoid SQL injection."""
if org_id and not _is_sqlite: if org_id is not None and not _is_sqlite:
db.execute( db.execute(text("SET LOCAL app.current_org_id = :org_id").bindparams(org_id=org_id or ""))
__import__("sqlalchemy").text(
f"SET LOCAL app.current_org_id = '{org_id}'"
)
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -151,14 +151,24 @@ app.add_middleware(RateLimitMiddleware)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Global exception handler # Exception handlers (specific first, then generic)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.exception_handler(Exception) from fastapi.exceptions import RequestValidationError
async def global_exception_handler(request: Request, exc: Exception): from pydantic import ValidationError
return JSONResponse( from sqlalchemy.exc import SQLAlchemyError, IntegrityError
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, from app.api.exceptions import (
content={"detail": "Internal server error", "type": type(exc).__name__}, 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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View 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
View 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"}

View File

@ -68,11 +68,9 @@ export default function KnowledgeGraphVisualization({
// Если не получилось через ESM, пробуем через require (для SSR) // Если не получилось через ESM, пробуем через require (для SSR)
if (!Network || !DataSet) { if (!Network || !DataSet) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const visNetworkReq = require('vis-network'); const visNetworkReq = require('vis-network');
Network = visNetworkReq.Network || visNetworkReq.default?.Network || visNetworkReq; Network = visNetworkReq.Network || visNetworkReq.default?.Network || visNetworkReq;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const visDataReq = require('vis-data'); const visDataReq = require('vis-data');
DataSet = visDataReq.DataSet || visDataReq.default?.DataSet || visDataReq; DataSet = visDataReq.DataSet || visDataReq.default?.DataSet || visDataReq;
} }

View 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
View 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'),
};

View File

@ -2,6 +2,7 @@
* Единый логгер приложения. Используйте вместо console.log. * Единый логгер приложения. Используйте вместо console.log.
* В production можно заменить вывод на отправку в систему мониторинга. * В production можно заменить вывод на отправку в систему мониторинга.
*/ */
/* eslint-disable no-console -- this file implements the logger using console */
const isDev = typeof process !== "undefined" && process.env?.NODE_ENV === "development"; const isDev = typeof process !== "undefined" && process.env?.NODE_ENV === "development";
function noop(): void {} function noop(): void {}

110
lib/ws-client.ts Normal file
View 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();

View File

@ -1,7 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
eslint: { ignoreDuringBuilds: true }, eslint: { ignoreDuringBuilds: false },
typescript: { ignoreBuildErrors: true }, typescript: { ignoreBuildErrors: false },
async rewrites() { async rewrites() {
// In development, proxy /api/v1/* to FastAPI backend // In development, proxy /api/v1/* to FastAPI backend
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000';