Initial commit: digital reception monorepo (M1-M11 + demo extensions)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# apps/api/.env.example
|
||||
# Обычно apps/api берёт env из корневого .env через NestJS ConfigModule,
|
||||
# но дублирующий файл здесь — для удобства самостоятельного запуска.
|
||||
|
||||
NODE_ENV=development
|
||||
API_PORT=4000
|
||||
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/reception
|
||||
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6380
|
||||
|
||||
FACE_SERVICE_URL=http://localhost:8001
|
||||
POLIMED_BASE_URL=http://localhost:4100
|
||||
WEB_ADMIN_ORIGIN=http://localhost:3000
|
||||
|
||||
JWT_ACCESS_SECRET=dev-access-secret-change-me-please
|
||||
JWT_REFRESH_SECRET=dev-refresh-secret-change-me-please
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=30d
|
||||
COOKIE_SECURE=false
|
||||
|
||||
MINIO_ENDPOINT=http://localhost:9000
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
MINIO_BUCKET=reception-evidence
|
||||
EVIDENCE_PRESIGN_TTL_SECONDS=900
|
||||
|
||||
CONSENT_REVOKE_DELAY_MS=86400000
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
rootDir: '.',
|
||||
testRegex: '\\.e2e-spec\\.ts$',
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.json', isolatedModules: true }],
|
||||
},
|
||||
testTimeout: 30000,
|
||||
setupFiles: ['<rootDir>/test/setup-env.ts'],
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@reception/api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config jest.e2e.config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.687.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.687.0",
|
||||
"@nestjs/bullmq": "^10.2.3",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@reception/db": "workspace:*",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^5.34.10",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@reception/eslint-config": "workspace:*",
|
||||
"@reception/tsconfig": "workspace:*",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"dotenv": "^16.4.7",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { join } from 'node:path';
|
||||
import { validateEnv } from './config/env.schema';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/guards/roles.guard';
|
||||
import { BiometryAccessLogInterceptor } from './auth/interceptors/biometry-access-log.interceptor';
|
||||
import { HealthController } from './health.controller';
|
||||
import { PolimedModule } from './polimed/polimed.module';
|
||||
import { FaceModule } from './face/face.module';
|
||||
import { EvidenceModule } from './evidence/evidence.module';
|
||||
import { TracksModule } from './tracks/tracks.module';
|
||||
import { EnrollmentModule } from './enrollment/enrollment.module';
|
||||
import { ConsentsModule } from './consents/consents.module';
|
||||
import { VisitsModule } from './visits/visits.module';
|
||||
import { RecognitionModule } from './recognition/recognition.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { IngestModule } from './ingest/ingest.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { CamerasModule } from './cameras/cameras.module';
|
||||
|
||||
const REPO_ROOT_ENV = join(__dirname, '..', '..', '..', '.env');
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
cache: true,
|
||||
envFilePath: ['.env', REPO_ROOT_ENV],
|
||||
validate: validateEnv,
|
||||
}),
|
||||
BullModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
connection: {
|
||||
host: config.getOrThrow<string>('REDIS_HOST'),
|
||||
port: config.getOrThrow<number>('REDIS_PORT'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
PolimedModule,
|
||||
FaceModule,
|
||||
EvidenceModule,
|
||||
TracksModule,
|
||||
EnrollmentModule,
|
||||
ConsentsModule,
|
||||
VisitsModule,
|
||||
RecognitionModule,
|
||||
AuditModule,
|
||||
IngestModule,
|
||||
DashboardModule,
|
||||
CamerasModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||
{ provide: APP_INTERCEPTOR, useClass: BiometryAccessLogInterceptor },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { Role } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller('audit')
|
||||
export class AuditController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Roles(Role.SYSADMIN)
|
||||
@Get('biometry')
|
||||
async biometry(
|
||||
@Query('actorUserId') actorUserId?: string,
|
||||
@Query('subjectPatientId') subjectPatientId?: string,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@Query('limit') limitRaw?: string,
|
||||
) {
|
||||
const take = Math.min(Number(limitRaw) || 100, 500);
|
||||
const where: Record<string, unknown> = {};
|
||||
if (actorUserId) where.actorUserId = actorUserId;
|
||||
if (subjectPatientId) where.subjectPatientId = subjectPatientId;
|
||||
if (from || to) {
|
||||
where.occurredAt = {} as Record<string, Date>;
|
||||
if (from) (where.occurredAt as Record<string, Date>).gte = new Date(from);
|
||||
if (to) (where.occurredAt as Record<string, Date>).lte = new Date(to);
|
||||
}
|
||||
|
||||
return this.prisma.biometryAccessLog.findMany({
|
||||
where,
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
include: { actor: { select: { email: true, fullName: true, role: true } } },
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditController } from './audit.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AuditController],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { AuthService, type AuthTokens } from './auth.service';
|
||||
import { CurrentUser, type AuthUser } from './decorators/current-user.decorator';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
class LoginDto {
|
||||
// Разрешаем email без TLD (для dev-юзеров senior@local и т.п.)
|
||||
@IsEmail({ require_tld: false })
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
const ACCESS_COOKIE = 'access_token';
|
||||
const REFRESH_COOKIE = 'refresh_token';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||
const tokens = await this.auth.login(dto.email, dto.password);
|
||||
this.setAuthCookies(res, tokens);
|
||||
return { ok: true, userId: tokens.userId };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@HttpCode(200)
|
||||
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
const refresh = req.cookies?.[REFRESH_COOKIE];
|
||||
const tokens = await this.auth.refresh(refresh);
|
||||
this.setAuthCookies(res, tokens);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
const refresh = req.cookies?.[REFRESH_COOKIE];
|
||||
await this.auth.logout(refresh);
|
||||
res.clearCookie(ACCESS_COOKIE);
|
||||
res.clearCookie(REFRESH_COOKIE);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return user;
|
||||
}
|
||||
|
||||
private setAuthCookies(res: Response, tokens: AuthTokens) {
|
||||
const secure = this.config.get<boolean>('COOKIE_SECURE') ?? false;
|
||||
const baseCookie: CookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
path: '/',
|
||||
};
|
||||
res.cookie(ACCESS_COOKIE, tokens.accessToken, {
|
||||
...baseCookie,
|
||||
maxAge: 15 * 60 * 1000,
|
||||
});
|
||||
res.cookie(REFRESH_COOKIE, tokens.refreshToken, {
|
||||
...baseCookie,
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
path: '/auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: config.get<string>('JWT_ACCESS_TTL') ?? '15m',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { JwtPayload } from './strategies/jwt.strategy';
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async validateUser(email: string, password: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user || !user.isActive) return null;
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<AuthTokens & { userId: string }> {
|
||||
const user = await this.validateUser(email, password);
|
||||
if (!user) throw new UnauthorizedException('Invalid credentials');
|
||||
const tokens = await this.issueTokens(user.id, user.email, user.role);
|
||||
return { ...tokens, userId: user.id };
|
||||
}
|
||||
|
||||
async issueTokens(userId: string, email: string, role: string): Promise<AuthTokens> {
|
||||
const payload: JwtPayload = { sub: userId, email, role };
|
||||
|
||||
const accessToken = await this.jwt.signAsync(payload, {
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
expiresIn: this.config.get<string>('JWT_ACCESS_TTL') ?? '15m',
|
||||
});
|
||||
|
||||
const refreshRaw = randomBytes(48).toString('hex');
|
||||
const refreshHash = this.hashToken(refreshRaw);
|
||||
const expiresAt = this.computeRefreshExpiry();
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: { userId, tokenHash: refreshHash, expiresAt },
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken: refreshRaw };
|
||||
}
|
||||
|
||||
async refresh(refreshTokenRaw: string): Promise<AuthTokens> {
|
||||
const tokenHash = this.hashToken(refreshTokenRaw);
|
||||
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
|
||||
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: stored.userId } });
|
||||
if (!user || !user.isActive) throw new UnauthorizedException('User inactive');
|
||||
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
return this.issueTokens(user.id, user.email, user.role);
|
||||
}
|
||||
|
||||
async logout(refreshTokenRaw: string | undefined) {
|
||||
if (!refreshTokenRaw) return;
|
||||
const tokenHash = this.hashToken(refreshTokenRaw);
|
||||
await this.prisma.refreshToken
|
||||
.updateMany({ where: { tokenHash, revokedAt: null }, data: { revokedAt: new Date() } })
|
||||
.catch((e) => {
|
||||
this.logger.warn(`Logout failed silently: ${e}`);
|
||||
});
|
||||
}
|
||||
|
||||
private hashToken(raw: string): string {
|
||||
return createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
private computeRefreshExpiry(): Date {
|
||||
const ttl = this.config.get<string>('JWT_REFRESH_TTL') ?? '30d';
|
||||
const match = ttl.match(/^(\d+)([smhd])$/);
|
||||
const now = Date.now();
|
||||
if (!match) {
|
||||
throw new ConflictException(`Invalid JWT_REFRESH_TTL: ${ttl}`);
|
||||
}
|
||||
const value = Number(match[1]);
|
||||
const unit = match[2];
|
||||
const multiplier = unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000;
|
||||
return new Date(now + value * multiplier);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { Role } from '@reception/db';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||
const req = ctx.switchToHttp().getRequest<Request & { user: AuthUser }>();
|
||||
return req.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const LOGS_BIOMETRY_KEY = 'logs_biometry';
|
||||
|
||||
/**
|
||||
* Помечает контроллер/маршрут, чьи вызовы должны логироваться в biometry_access_log.
|
||||
* Используется BiometryAccessLogInterceptor.
|
||||
*
|
||||
* @param action — произвольная метка действия (например, 'enroll', 'recognize', 'view_visits').
|
||||
*/
|
||||
export const LogsBiometry = (action: string) => SetMetadata(LOGS_BIOMETRY_KEY, action);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'is_public';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from '@reception/db';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role } from '@reception/db';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import type { AuthUser } from '../decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<Role[] | undefined>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!required || required.length === 0) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user: AuthUser | undefined = req.user;
|
||||
if (!user) throw new ForbiddenException('No user context');
|
||||
if (!required.includes(user.role)) {
|
||||
throw new ForbiddenException(`Required role: ${required.join(' or ')}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { AuthModule } from './auth.module';
|
||||
export { AuthService } from './auth.service';
|
||||
export { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
export { RolesGuard } from './guards/roles.guard';
|
||||
export { BiometryAccessLogInterceptor } from './interceptors/biometry-access-log.interceptor';
|
||||
export { Roles } from './decorators/roles.decorator';
|
||||
export { Public } from './decorators/public.decorator';
|
||||
export { LogsBiometry } from './decorators/logs-biometry.decorator';
|
||||
export { CurrentUser, type AuthUser } from './decorators/current-user.decorator';
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { LOGS_BIOMETRY_KEY } from '../decorators/logs-biometry.decorator';
|
||||
import type { AuthUser } from '../decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class BiometryAccessLogInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(BiometryAccessLogInterceptor.name);
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const action = this.reflector.getAllAndOverride<string | undefined>(LOGS_BIOMETRY_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!action) return next.handle();
|
||||
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user: AuthUser | undefined = req.user;
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((responseBody) => {
|
||||
// Извлекаем subject (UUID нашего Patient) из:
|
||||
// 1. params.patientId, 2. body.patientId, 3. responseBody.patientId (для enrollment).
|
||||
const candidates = [
|
||||
req.params?.patientId,
|
||||
req.body?.patientId,
|
||||
(responseBody as { patientId?: string } | null)?.patientId,
|
||||
];
|
||||
const subjectPatientId = candidates.find((c) => typeof c === 'string' && UUID_RE.test(c)) ?? null;
|
||||
|
||||
this.prisma.biometryAccessLog
|
||||
.create({
|
||||
data: {
|
||||
action,
|
||||
requestPath: req.originalUrl ?? req.url,
|
||||
actorUserId: user?.id ?? null,
|
||||
subjectPatientId,
|
||||
},
|
||||
})
|
||||
.catch((err) => this.logger.error(`Failed to write biometry access log: ${err}`));
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
@@ -0,0 +1 @@
|
||||
export { Role } from '@reception/db';
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import type { Request } from 'express';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { AuthUser } from '../decorators/current-user.decorator';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(req: Request) => req?.cookies?.access_token ?? null,
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<AuthUser> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('User not found or inactive');
|
||||
}
|
||||
return { id: user.id, email: user.email, role: user.role };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller('cameras')
|
||||
export class CamerasController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
async list() {
|
||||
const cameras = await this.prisma.camera.findMany({
|
||||
include: { zone: true },
|
||||
orderBy: [{ zone: { code: 'asc' } }, { name: 'asc' }],
|
||||
});
|
||||
return cameras.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
zoneCode: c.zone.code,
|
||||
zoneName: c.zone.name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CamerasController } from './cameras.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [CamerasController],
|
||||
})
|
||||
export class CamerasModule {}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
|
||||
API_PORT: z.coerce.number().int().positive().default(4000),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
REDIS_HOST: z.string().default('localhost'),
|
||||
REDIS_PORT: z.coerce.number().int().positive().default(6380),
|
||||
|
||||
FACE_SERVICE_URL: z.string().url().default('http://localhost:8001'),
|
||||
POLIMED_BASE_URL: z.string().url().default('http://localhost:4100'),
|
||||
|
||||
WEB_ADMIN_ORIGIN: z.string().url().default('http://localhost:3000'),
|
||||
|
||||
JWT_ACCESS_SECRET: z.string().min(16),
|
||||
JWT_REFRESH_SECRET: z.string().min(16),
|
||||
JWT_ACCESS_TTL: z.string().default('15m'),
|
||||
JWT_REFRESH_TTL: z.string().default('30d'),
|
||||
COOKIE_SECURE: z.enum(['true', 'false']).default('false').transform((v) => v === 'true'),
|
||||
|
||||
MINIO_ENDPOINT: z.string().url().default('http://localhost:9000'),
|
||||
MINIO_ROOT_USER: z.string().default('minioadmin'),
|
||||
MINIO_ROOT_PASSWORD: z.string().default('minioadmin'),
|
||||
MINIO_BUCKET: z.string().default('reception-evidence'),
|
||||
|
||||
EVIDENCE_PRESIGN_TTL_SECONDS: z.coerce.number().int().positive().default(900),
|
||||
|
||||
CONSENT_REVOKE_DELAY_MS: z.coerce.number().int().nonnegative().default(86_400_000),
|
||||
});
|
||||
|
||||
export type AppEnv = z.infer<typeof envSchema>;
|
||||
|
||||
export function validateEnv(raw: Record<string, unknown>): AppEnv {
|
||||
const parsed = envSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
|
||||
throw new Error(`Invalid environment configuration:\n${issues}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { ConsentAction, ConsentRevocationStatus, TrackStatus } from '@reception/db';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { FaceClient } from '../face/face.client';
|
||||
|
||||
export const CONSENT_REVOCATION_QUEUE = 'consent-revocation';
|
||||
|
||||
export interface ConsentRevocationJobData {
|
||||
jobId: string;
|
||||
patientId: string;
|
||||
actorUserId: string;
|
||||
}
|
||||
|
||||
@Processor(CONSENT_REVOCATION_QUEUE)
|
||||
export class ConsentRevocationProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(ConsentRevocationProcessor.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly face: FaceClient,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<ConsentRevocationJobData>): Promise<void> {
|
||||
const { jobId, patientId, actorUserId } = job.data;
|
||||
this.logger.log(`Executing consent revocation for patient ${patientId} (job=${jobId})`);
|
||||
|
||||
const deletedEmbeddings = await this.face.deletePatientEmbeddings(patientId);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.patient.update({
|
||||
where: { id: patientId },
|
||||
data: { fullName: null, pendingDeletionAt: null },
|
||||
});
|
||||
|
||||
await tx.track.updateMany({
|
||||
where: { patientId },
|
||||
data: { status: TrackStatus.ANONYMIZED },
|
||||
});
|
||||
|
||||
await tx.patientConsent.create({
|
||||
data: {
|
||||
patientId,
|
||||
action: ConsentAction.REVOKED,
|
||||
paperRef: 'revocation-completed',
|
||||
actorUserId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.consentRevocationJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: ConsentRevocationStatus.DONE, completedAt: new Date() },
|
||||
});
|
||||
|
||||
await tx.biometryAccessLog.create({
|
||||
data: {
|
||||
action: 'consent_revocation_completed',
|
||||
actorUserId,
|
||||
subjectPatientId: patientId,
|
||||
requestPath: 'queue:consent-revocation',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Consent revocation done: patient=${patientId}, deleted_embeddings=${deletedEmbeddings}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller, HttpCode, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { Role } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
|
||||
import { CurrentUser, type AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { ConsentsService } from './consents.service';
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller('consents')
|
||||
export class ConsentsController {
|
||||
constructor(private readonly consents: ConsentsService) {}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN)
|
||||
@LogsBiometry('consent_revoke')
|
||||
@Post(':patientId/revoke')
|
||||
@HttpCode(202)
|
||||
revoke(@Param('patientId') patientId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.consents.revoke(patientId, user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConsentsController } from './consents.controller';
|
||||
import { ConsentsService } from './consents.service';
|
||||
import { CONSENT_REVOCATION_QUEUE, ConsentRevocationProcessor } from './consent-revocation.processor';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: CONSENT_REVOCATION_QUEUE })],
|
||||
controllers: [ConsentsController],
|
||||
providers: [ConsentsService, ConsentRevocationProcessor],
|
||||
exports: [ConsentsService],
|
||||
})
|
||||
export class ConsentsModule {}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Queue } from 'bullmq';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CONSENT_REVOCATION_QUEUE,
|
||||
type ConsentRevocationJobData,
|
||||
} from './consent-revocation.processor';
|
||||
|
||||
@Injectable()
|
||||
export class ConsentsService {
|
||||
private readonly logger = new Logger(ConsentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
@InjectQueue(CONSENT_REVOCATION_QUEUE) private readonly queue: Queue<ConsentRevocationJobData>,
|
||||
) {}
|
||||
|
||||
private getDelayMs(): number {
|
||||
// Читаем динамически — env может быть переопределён в тестах перед вызовом.
|
||||
return this.config.get<number>('CONSENT_REVOKE_DELAY_MS') ?? 86_400_000;
|
||||
}
|
||||
|
||||
async revoke(patientId: string, actorUserId: string) {
|
||||
const patient = await this.prisma.patient.findUnique({ where: { id: patientId } });
|
||||
if (!patient) throw new NotFoundException(`Patient ${patientId} not found`);
|
||||
|
||||
const delayMs = this.getDelayMs();
|
||||
const scheduledFor = new Date(Date.now() + delayMs);
|
||||
|
||||
const job = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.patient.update({
|
||||
where: { id: patientId },
|
||||
data: { consentRevokedAt: new Date(), pendingDeletionAt: scheduledFor },
|
||||
});
|
||||
|
||||
return tx.consentRevocationJob.create({
|
||||
data: {
|
||||
patientId,
|
||||
revokedAt: new Date(),
|
||||
scheduledFor,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.queue.add(
|
||||
'revoke',
|
||||
{ jobId: job.id, patientId, actorUserId },
|
||||
{ delay: delayMs, removeOnComplete: true, removeOnFail: false },
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Scheduled consent revocation: patient=${patientId} → ${scheduledFor.toISOString()} (delay=${delayMs}ms)`,
|
||||
);
|
||||
|
||||
return { jobId: job.id, scheduledFor };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { Role } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboard: DashboardService) {}
|
||||
|
||||
@Roles(Role.MANAGER, Role.SYSADMIN, Role.SENIOR_ADMIN)
|
||||
@Get('overview')
|
||||
overview(@Query('date') date?: string) {
|
||||
const dateIso = date ?? new Date().toISOString().slice(0, 10);
|
||||
return this.dashboard.overview({ dateIso });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
interface OverviewOpts {
|
||||
dateIso: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export interface KpiCard {
|
||||
label: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
hint?: string;
|
||||
/** true — данные синтетические / неполные (нужен Ф0 для realistic). */
|
||||
synthetic?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async overview(opts: OverviewOpts) {
|
||||
const start = new Date(`${opts.dateIso}T00:00:00.000Z`);
|
||||
const end = new Date(`${opts.dateIso}T23:59:59.999Z`);
|
||||
|
||||
const [
|
||||
visitsToday,
|
||||
newEnrollmentsToday,
|
||||
unmatchedTracks,
|
||||
leftWithoutService,
|
||||
visitsWithService,
|
||||
tracksToday,
|
||||
visitsByHour,
|
||||
eventsByZone,
|
||||
zoneTimeStats,
|
||||
] = await Promise.all([
|
||||
this.prisma.visit.count({ where: { arrivedAt: { gte: start, lte: end } } }),
|
||||
|
||||
this.prisma.patientConsent.count({
|
||||
where: { action: 'GRANTED', occurredAt: { gte: start, lte: end } },
|
||||
}),
|
||||
|
||||
this.prisma.track.count({
|
||||
where: { status: 'UNMATCHED', firstSeenAt: { gte: start, lte: end } },
|
||||
}),
|
||||
|
||||
this.prisma.visit.count({
|
||||
where: { arrivedAt: { gte: start, lte: end }, leftWithoutService: true },
|
||||
}),
|
||||
|
||||
this.prisma.visit.findMany({
|
||||
where: {
|
||||
arrivedAt: { gte: start, lte: end },
|
||||
serviceStartedAt: { not: null },
|
||||
serviceEndedAt: { not: null },
|
||||
},
|
||||
select: { arrivedAt: true, serviceStartedAt: true, serviceEndedAt: true },
|
||||
}),
|
||||
|
||||
this.prisma.track.count({
|
||||
where: { firstSeenAt: { gte: start, lte: end } },
|
||||
}),
|
||||
|
||||
this.prisma.$queryRaw<Array<{ hour: number; visits: bigint }>>`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM arrived_at)::int AS hour,
|
||||
COUNT(*)::bigint AS visits
|
||||
FROM visits
|
||||
WHERE arrived_at >= ${start} AND arrived_at <= ${end}
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`,
|
||||
|
||||
this.prisma.$queryRaw<Array<{ code: string; events: bigint; tracks: bigint }>>`
|
||||
SELECT
|
||||
z.code::text AS code,
|
||||
COUNT(te.*)::bigint AS events,
|
||||
COUNT(DISTINCT te.track_id)::bigint AS tracks
|
||||
FROM zones z
|
||||
LEFT JOIN track_events te ON te.zone_id = z.id
|
||||
AND te.occurred_at >= ${start} AND te.occurred_at <= ${end}
|
||||
GROUP BY z.code
|
||||
ORDER BY z.code
|
||||
`,
|
||||
|
||||
// Среднее время в зоне для каждой зоны: для каждого трека
|
||||
// считаем разницу между первым и последним событием в зоне.
|
||||
this.prisma.$queryRaw<Array<{ code: string; avg_seconds: number }>>`
|
||||
WITH zone_segments AS (
|
||||
SELECT
|
||||
z.code::text AS code,
|
||||
te.track_id,
|
||||
EXTRACT(EPOCH FROM (MAX(te.occurred_at) - MIN(te.occurred_at)))::float AS seconds
|
||||
FROM track_events te
|
||||
JOIN zones z ON z.id = te.zone_id
|
||||
WHERE te.occurred_at >= ${start} AND te.occurred_at <= ${end}
|
||||
GROUP BY z.code, te.track_id
|
||||
HAVING COUNT(*) >= 2
|
||||
)
|
||||
SELECT code, AVG(seconds)::float AS avg_seconds
|
||||
FROM zone_segments
|
||||
GROUP BY code
|
||||
ORDER BY code
|
||||
`,
|
||||
]);
|
||||
|
||||
const avgWaitingSec = avg(
|
||||
visitsWithService.map(
|
||||
(v) => (v.serviceStartedAt!.getTime() - v.arrivedAt.getTime()) / 1000,
|
||||
),
|
||||
);
|
||||
const avgServiceSec = avg(
|
||||
visitsWithService.map(
|
||||
(v) => (v.serviceEndedAt!.getTime() - v.serviceStartedAt!.getTime()) / 1000,
|
||||
),
|
||||
);
|
||||
|
||||
// Подтянем «in flight» — пациентов сейчас в клинике (есть `arrived`, нет `service_ended` и не вышел через `left_without_service`).
|
||||
const liveQueue = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(DISTINCT t.id)::bigint AS count
|
||||
FROM tracks t
|
||||
WHERE t.first_seen_at >= ${start} AND t.last_seen_at >= NOW() - INTERVAL '30 minutes'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM track_events te
|
||||
WHERE te.track_id = t.id
|
||||
AND te.type IN ('service_ended', 'left_without_service')
|
||||
)
|
||||
`;
|
||||
|
||||
const cards: KpiCard[] = [
|
||||
{ label: 'Визитов сегодня', value: visitsToday, unit: 'шт', hint: 'из таблицы visits' },
|
||||
{
|
||||
label: 'Новых enrollment',
|
||||
value: newEnrollmentsToday,
|
||||
unit: 'шт',
|
||||
hint: 'согласие GRANTED сегодня',
|
||||
},
|
||||
{
|
||||
label: 'Активных треков',
|
||||
value: Number(liveQueue[0]?.count ?? 0),
|
||||
unit: 'шт',
|
||||
hint: 'не закрыты service_ended/left_without_service',
|
||||
},
|
||||
{
|
||||
label: 'Unmatched-треков',
|
||||
value: unmatchedTracks,
|
||||
unit: 'шт',
|
||||
hint: 'ждут ручного enrollment',
|
||||
},
|
||||
{
|
||||
label: 'Среднее ожидание',
|
||||
value: Math.round(avgWaitingSec ?? 0),
|
||||
unit: 'сек',
|
||||
hint: 'arrived → service_started',
|
||||
synthetic: visitsWithService.length === 0,
|
||||
},
|
||||
{
|
||||
label: 'Среднее обслуживание',
|
||||
value: Math.round(avgServiceSec ?? 0),
|
||||
unit: 'сек',
|
||||
hint: 'service_started → service_ended',
|
||||
synthetic: visitsWithService.length === 0,
|
||||
},
|
||||
{
|
||||
label: 'Ушли без обслуживания',
|
||||
value: leftWithoutService,
|
||||
unit: 'шт',
|
||||
hint: 'left_without_service за день',
|
||||
},
|
||||
{
|
||||
label: 'Треков создано',
|
||||
value: tracksToday,
|
||||
unit: 'шт',
|
||||
hint: 'все треки за день, включая повторные узнавания',
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
date: opts.dateIso,
|
||||
cards,
|
||||
visitsByHour: visitsByHour.map((v) => ({ hour: v.hour, visits: Number(v.visits) })),
|
||||
zoneActivity: eventsByZone.map((z) => ({
|
||||
code: z.code,
|
||||
events: Number(z.events),
|
||||
tracks: Number(z.tracks),
|
||||
})),
|
||||
avgTimeInZoneSec: zoneTimeStats.map((s) => ({
|
||||
code: s.code,
|
||||
seconds: Math.round(s.avg_seconds),
|
||||
})),
|
||||
hasRealData: visitsWithService.length > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function avg(arr: number[]): number | null {
|
||||
if (arr.length === 0) return null;
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
|
||||
import { Role } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
|
||||
import { CurrentUser, type AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { EnrollmentService } from './enrollment.service';
|
||||
|
||||
class EnrollmentDto {
|
||||
@IsUUID()
|
||||
trackId!: string;
|
||||
|
||||
@IsString()
|
||||
polimedPatientId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
polimedAppointmentId?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
paperConsentRef!: string;
|
||||
}
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller('enrollment')
|
||||
export class EnrollmentController {
|
||||
constructor(private readonly enrollment: EnrollmentService) {}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN)
|
||||
@LogsBiometry('enroll')
|
||||
@Post()
|
||||
enroll(@Body() dto: EnrollmentDto, @CurrentUser() user: AuthUser) {
|
||||
return this.enrollment.enroll({
|
||||
trackId: dto.trackId,
|
||||
polimedPatientId: dto.polimedPatientId,
|
||||
polimedAppointmentId: dto.polimedAppointmentId,
|
||||
paperConsentRef: dto.paperConsentRef,
|
||||
actorUserId: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EnrollmentController } from './enrollment.controller';
|
||||
import { EnrollmentService } from './enrollment.service';
|
||||
|
||||
@Module({
|
||||
controllers: [EnrollmentController],
|
||||
providers: [EnrollmentService],
|
||||
})
|
||||
export class EnrollmentModule {}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConsentAction, TrackStatus } from '@reception/db';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { FaceClient } from '../face/face.client';
|
||||
import { PolimedClient } from '../polimed/polimed.client';
|
||||
|
||||
interface EnrollmentOpts {
|
||||
trackId: string;
|
||||
polimedPatientId: string;
|
||||
polimedAppointmentId?: string;
|
||||
paperConsentRef: string;
|
||||
actorUserId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EnrollmentService {
|
||||
private readonly logger = new Logger(EnrollmentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly face: FaceClient,
|
||||
private readonly polimed: PolimedClient,
|
||||
) {}
|
||||
|
||||
async enroll(opts: EnrollmentOpts) {
|
||||
const track = await this.prisma.track.findUnique({
|
||||
where: { id: opts.trackId },
|
||||
include: { _count: { select: { faceEmbeddings: true } } },
|
||||
});
|
||||
if (!track) throw new NotFoundException(`Track ${opts.trackId} not found`);
|
||||
if (track.patientId) {
|
||||
throw new BadRequestException(`Track ${opts.trackId} уже привязан к пациенту`);
|
||||
}
|
||||
if (track._count.faceEmbeddings === 0) {
|
||||
throw new BadRequestException(`У трека ${opts.trackId} нет эмбеддингов`);
|
||||
}
|
||||
|
||||
// 1. Получаем данные пациента из Полимед.
|
||||
const polimedAppointment = opts.polimedAppointmentId
|
||||
? await this.polimed.getAppointment(opts.polimedAppointmentId).catch(() => null)
|
||||
: null;
|
||||
|
||||
const polimedPatients = await this.polimed.searchPatients(opts.polimedPatientId, 50);
|
||||
const polimedPatient = polimedPatients.find((p) => p.id === opts.polimedPatientId)
|
||||
?? polimedAppointment;
|
||||
const fullName = polimedAppointment?.patientFullName
|
||||
?? polimedPatients.find((p) => p.id === opts.polimedPatientId)?.fullName
|
||||
?? null;
|
||||
|
||||
if (!fullName) {
|
||||
this.logger.warn(`Не нашли полное ФИО в Полимед для ${opts.polimedPatientId}`);
|
||||
}
|
||||
|
||||
// 2. Транзакция: создаём/обновляем Patient + Consent + Track + Visit.
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const patient = await tx.patient.upsert({
|
||||
where: { polimedPatientId: opts.polimedPatientId },
|
||||
update: {
|
||||
fullName: fullName ?? undefined,
|
||||
consentReceivedAt: new Date(),
|
||||
consentRevokedAt: null,
|
||||
pendingDeletionAt: null,
|
||||
},
|
||||
create: {
|
||||
polimedPatientId: opts.polimedPatientId,
|
||||
fullName: fullName ?? 'Без ФИО',
|
||||
consentReceivedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.patientConsent.create({
|
||||
data: {
|
||||
patientId: patient.id,
|
||||
action: ConsentAction.GRANTED,
|
||||
paperRef: opts.paperConsentRef,
|
||||
actorUserId: opts.actorUserId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.track.update({
|
||||
where: { id: opts.trackId },
|
||||
data: { patientId: patient.id, status: TrackStatus.MATCHED },
|
||||
});
|
||||
|
||||
const visit = await tx.visit.create({
|
||||
data: {
|
||||
patientId: patient.id,
|
||||
polimedAppointmentId: opts.polimedAppointmentId ?? null,
|
||||
arrivedAt: track.firstSeenAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { patient, visit };
|
||||
});
|
||||
|
||||
// 3. Привязываем эмбеддинги трека к пациенту в face-service.
|
||||
const attached = await this.face.enrollTrack(opts.trackId, result.patient.id);
|
||||
|
||||
if (opts.polimedAppointmentId) {
|
||||
this.polimed
|
||||
.pushVisitEvent(opts.polimedAppointmentId, {
|
||||
type: 'arrived',
|
||||
occurredAt: track.firstSeenAt.toISOString(),
|
||||
})
|
||||
.catch((err) => this.logger.warn(`Polimed pushVisitEvent failed: ${err}`));
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Enrollment: track=${opts.trackId} → patient=${result.patient.id}, embeddings=${attached}`,
|
||||
);
|
||||
|
||||
return {
|
||||
patientId: result.patient.id,
|
||||
visitId: result.visit.id,
|
||||
embeddingsAttached: attached,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EvidenceService } from './evidence.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EvidenceService],
|
||||
exports: [EvidenceService],
|
||||
})
|
||||
export class EvidenceModule {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
@Injectable()
|
||||
export class EvidenceService implements OnModuleInit {
|
||||
private readonly logger = new Logger(EvidenceService.name);
|
||||
private readonly s3: S3Client;
|
||||
private readonly bucket: string;
|
||||
private readonly presignTtl: number;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.s3 = new S3Client({
|
||||
endpoint: config.getOrThrow<string>('MINIO_ENDPOINT'),
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: config.getOrThrow<string>('MINIO_ROOT_USER'),
|
||||
secretAccessKey: config.getOrThrow<string>('MINIO_ROOT_PASSWORD'),
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
this.bucket = config.getOrThrow<string>('MINIO_BUCKET');
|
||||
this.presignTtl = config.get<number>('EVIDENCE_PRESIGN_TTL_SECONDS') ?? 900;
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
} catch {
|
||||
try {
|
||||
await this.s3.send(new CreateBucketCommand({ Bucket: this.bucket }));
|
||||
this.logger.log(`Created MinIO bucket "${this.bucket}"`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`MinIO bucket "${this.bucket}" unavailable: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Сохраняет JPEG-кадр в MinIO. Возвращает object key. */
|
||||
async putEvidence(jpegBuffer: Buffer, opts: { trackId: string; cameraId: string }): Promise<string> {
|
||||
const key = `tracks/${opts.trackId}/${opts.cameraId}-${Date.now()}-${randomUUID()}.jpg`;
|
||||
await this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: jpegBuffer,
|
||||
ContentType: 'image/jpeg',
|
||||
}),
|
||||
);
|
||||
return key;
|
||||
}
|
||||
|
||||
async getPresignedUrl(key: string, ttlSeconds?: number): Promise<string> {
|
||||
const cmd = new GetObjectCommand({ Bucket: this.bucket, Key: key });
|
||||
return getSignedUrl(this.s3, cmd, { expiresIn: ttlSeconds ?? this.presignTtl });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface RecognizeResult {
|
||||
patient_id: string;
|
||||
confidence: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FaceClient {
|
||||
private readonly logger = new Logger(FaceClient.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.baseUrl = config.getOrThrow<string>('FACE_SERVICE_URL');
|
||||
}
|
||||
|
||||
async recognize(frameBase64: string): Promise<RecognizeResult | null> {
|
||||
const res = await fetch(`${this.baseUrl}/recognize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ frame: frameBase64 }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`face-service /recognize failed: ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
const json = (await res.json()) as RecognizeResult | null;
|
||||
return json;
|
||||
}
|
||||
|
||||
async enrollTrack(trackId: string, patientId: string): Promise<number> {
|
||||
const res = await fetch(`${this.baseUrl}/enroll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ track_id: trackId, patient_id: patientId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`face-service /enroll failed: ${res.status}`);
|
||||
}
|
||||
const json = (await res.json()) as { embeddings_attached: number };
|
||||
return json.embeddings_attached;
|
||||
}
|
||||
|
||||
async deletePatientEmbeddings(patientId: string): Promise<number> {
|
||||
const res = await fetch(`${this.baseUrl}/patient/${patientId}/embeddings`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`face-service DELETE /patient/.../embeddings failed: ${res.status}`);
|
||||
return 0;
|
||||
}
|
||||
const json = (await res.json()) as { deleted: number };
|
||||
return json.deleted;
|
||||
}
|
||||
|
||||
async countPatientEmbeddings(patientId: string): Promise<number> {
|
||||
const res = await fetch(`${this.baseUrl}/patient/${patientId}/count`);
|
||||
if (!res.ok) return 0;
|
||||
const json = (await res.json()) as { count: number };
|
||||
return json.count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { FaceClient } from './face.client';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [FaceClient],
|
||||
exports: [FaceClient],
|
||||
})
|
||||
export class FaceModule {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from './auth/decorators/public.decorator';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Public()
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok', service: 'reception-api' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { IsEnum, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { TrackEventType, ZoneCode } from '@reception/db';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { IngestService } from './ingest.service';
|
||||
|
||||
class CreateTrackDto {
|
||||
@IsString()
|
||||
cameraName!: string;
|
||||
|
||||
@IsISO8601()
|
||||
firstSeenAt!: string;
|
||||
}
|
||||
|
||||
class AddEventDto {
|
||||
@IsUUID()
|
||||
trackId!: string;
|
||||
|
||||
@IsEnum(['arrived', 'waiting', 'service_started', 'service_ended', 'left_without_service'])
|
||||
type!: TrackEventType;
|
||||
|
||||
@IsString()
|
||||
cameraName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['A', 'B', 'C'])
|
||||
zoneCode?: ZoneCode;
|
||||
|
||||
@IsISO8601()
|
||||
occurredAt!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
evidenceKey?: string;
|
||||
}
|
||||
|
||||
class CaptureFrameDto {
|
||||
@IsUUID()
|
||||
trackId!: string;
|
||||
|
||||
@IsString()
|
||||
cameraName!: string;
|
||||
|
||||
@IsString()
|
||||
frame!: string;
|
||||
}
|
||||
|
||||
@Controller('ingest')
|
||||
export class IngestController {
|
||||
constructor(private readonly ingest: IngestService) {}
|
||||
|
||||
@Public()
|
||||
@Post('tracks')
|
||||
createTrack(@Body() dto: CreateTrackDto) {
|
||||
return this.ingest.createTrack({
|
||||
cameraName: dto.cameraName,
|
||||
firstSeenAt: new Date(dto.firstSeenAt),
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('track-events')
|
||||
addEvent(@Body() dto: AddEventDto) {
|
||||
return this.ingest.addEvent({
|
||||
trackId: dto.trackId,
|
||||
type: dto.type,
|
||||
cameraName: dto.cameraName,
|
||||
zoneCode: dto.zoneCode,
|
||||
occurredAt: new Date(dto.occurredAt),
|
||||
evidenceKey: dto.evidenceKey,
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('capture-frame')
|
||||
captureFrame(@Body() dto: CaptureFrameDto) {
|
||||
return this.ingest.captureFrame({
|
||||
trackId: dto.trackId,
|
||||
cameraName: dto.cameraName,
|
||||
frameBase64: dto.frame,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IngestController } from './ingest.controller';
|
||||
import { IngestService } from './ingest.service';
|
||||
|
||||
@Module({
|
||||
controllers: [IngestController],
|
||||
providers: [IngestService],
|
||||
exports: [IngestService],
|
||||
})
|
||||
export class IngestModule {}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TrackEventType, ZoneCode } from '@reception/db';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EvidenceService } from '../evidence/evidence.service';
|
||||
|
||||
interface CreateTrackOpts {
|
||||
cameraName: string;
|
||||
firstSeenAt: Date;
|
||||
}
|
||||
|
||||
interface AddEventOpts {
|
||||
trackId: string;
|
||||
type: TrackEventType;
|
||||
cameraName: string;
|
||||
zoneCode?: ZoneCode;
|
||||
occurredAt: Date;
|
||||
evidenceKey?: string;
|
||||
}
|
||||
|
||||
interface CaptureFrameOpts {
|
||||
trackId: string;
|
||||
cameraName: string;
|
||||
frameBase64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal ingest API для video-ingest и fixtures-runner. Помечен Public
|
||||
* (нет JWT в Ф1) — в проде будет service-to-service токен.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IngestService {
|
||||
private readonly logger = new Logger(IngestService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly evidence: EvidenceService,
|
||||
) {}
|
||||
|
||||
async createTrack(opts: CreateTrackOpts) {
|
||||
const camera = await this.prisma.camera.findUnique({ where: { name: opts.cameraName } });
|
||||
if (!camera) throw new NotFoundException(`Camera "${opts.cameraName}" not found`);
|
||||
|
||||
const track = await this.prisma.track.create({
|
||||
data: { firstSeenAt: opts.firstSeenAt, lastSeenAt: opts.firstSeenAt },
|
||||
});
|
||||
|
||||
this.logger.log(`Track created: ${track.id} (camera=${opts.cameraName})`);
|
||||
return { trackId: track.id, cameraId: camera.id, zoneId: camera.zoneId };
|
||||
}
|
||||
|
||||
async addEvent(opts: AddEventOpts) {
|
||||
const camera = await this.prisma.camera.findUnique({
|
||||
where: { name: opts.cameraName },
|
||||
include: { zone: true },
|
||||
});
|
||||
if (!camera) throw new NotFoundException(`Camera "${opts.cameraName}" not found`);
|
||||
|
||||
if (opts.zoneCode && camera.zone.code !== opts.zoneCode) {
|
||||
throw new BadRequestException(
|
||||
`Camera "${opts.cameraName}" привязана к зоне ${camera.zone.code}, не к ${opts.zoneCode}`,
|
||||
);
|
||||
}
|
||||
|
||||
const track = await this.prisma.track.findUnique({ where: { id: opts.trackId } });
|
||||
if (!track) throw new NotFoundException(`Track ${opts.trackId} not found`);
|
||||
|
||||
const event = await this.prisma.trackEvent.create({
|
||||
data: {
|
||||
trackId: opts.trackId,
|
||||
type: opts.type,
|
||||
cameraId: camera.id,
|
||||
zoneId: camera.zone.id,
|
||||
occurredAt: opts.occurredAt,
|
||||
evidenceKey: opts.evidenceKey ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Обновляем lastSeenAt трека.
|
||||
if (opts.occurredAt > track.lastSeenAt) {
|
||||
await this.prisma.track.update({
|
||||
where: { id: opts.trackId },
|
||||
data: { lastSeenAt: opts.occurredAt },
|
||||
});
|
||||
}
|
||||
|
||||
return { eventId: event.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Принимает base64 JPEG из браузера, шлёт в face-service /track-embeddings,
|
||||
* получает id сохранённого эмбеддинга (либо null, если лицо не найдено).
|
||||
* Параллельно вызывает face-service /recognize чтобы узнать, есть ли уже
|
||||
* привязанный пациент (для UI «узнан/не узнан»).
|
||||
*/
|
||||
async captureFrame(opts: CaptureFrameOpts) {
|
||||
const camera = await this.prisma.camera.findUniqueOrThrow({
|
||||
where: { name: opts.cameraName },
|
||||
include: { zone: true },
|
||||
});
|
||||
const track = await this.prisma.track.findUnique({
|
||||
where: { id: opts.trackId },
|
||||
include: { _count: { select: { events: true } } },
|
||||
});
|
||||
if (!track) throw new NotFoundException(`Track ${opts.trackId} not found`);
|
||||
|
||||
const faceServiceUrl = this.config.getOrThrow<string>('FACE_SERVICE_URL');
|
||||
const now = new Date();
|
||||
|
||||
type EmbedRes = {
|
||||
id: string;
|
||||
quality: number;
|
||||
bbox: { box: number[]; imgW: number; imgH: number } | null;
|
||||
} | null;
|
||||
type RecogRes = { patient_id: string; confidence: number; distance: number } | null;
|
||||
|
||||
const [embedRes, recogRes] = (await Promise.all([
|
||||
fetch(`${faceServiceUrl}/track-embeddings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
frame: opts.frameBase64,
|
||||
track_id: opts.trackId,
|
||||
camera_id: camera.id,
|
||||
captured_at: now.toISOString(),
|
||||
}),
|
||||
}).then((r) => (r.ok ? (r.json() as Promise<EmbedRes>) : null)),
|
||||
fetch(`${faceServiceUrl}/recognize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ frame: opts.frameBase64 }),
|
||||
}).then((r) => (r.ok ? (r.json() as Promise<RecogRes>) : null)),
|
||||
]).catch((err) => {
|
||||
this.logger.warn(`captureFrame face-service error: ${err}`);
|
||||
return [null, null] as const;
|
||||
})) as [EmbedRes, RecogRes];
|
||||
|
||||
// Сохраняем JPEG в MinIO (только когда лицо обнаружено — нет смысла копить пустые кадры).
|
||||
let evidenceKey: string | null = null;
|
||||
if (embedRes) {
|
||||
try {
|
||||
const jpegBuffer = decodeBase64Jpeg(opts.frameBase64);
|
||||
evidenceKey = await this.evidence.putEvidence(jpegBuffer, {
|
||||
trackId: opts.trackId,
|
||||
cameraId: camera.id,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`MinIO put failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Каждый удачный кадр → событие. Первый — `arrived`, остальные — `waiting`.
|
||||
// Так на /enrollment/[id] видна вся серия кадров.
|
||||
if (embedRes && evidenceKey) {
|
||||
await this.prisma.trackEvent.create({
|
||||
data: {
|
||||
trackId: opts.trackId,
|
||||
type: track._count.events === 0 ? 'arrived' : 'waiting',
|
||||
cameraId: camera.id,
|
||||
zoneId: camera.zone.id,
|
||||
occurredAt: now,
|
||||
evidenceKey,
|
||||
faceBbox: (embedRes.bbox as unknown as object) ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Если узнан — подтягиваем пациента и АВТО-ПРИВЯЗЫВАЕМ к нему текущий трек.
|
||||
// Без этого каждая сессия на /capture создаёт unmatched-трек, и маршрут пациента
|
||||
// остаётся обрезанным (видим только первый enrollment-трек).
|
||||
let recognizedPatient: { id: string; fullName: string | null } | null = null;
|
||||
if (recogRes) {
|
||||
const p = await this.prisma.patient.findUnique({
|
||||
where: { id: recogRes.patient_id },
|
||||
select: { id: true, fullName: true, consentRevokedAt: true, pendingDeletionAt: true },
|
||||
});
|
||||
if (p && !p.consentRevokedAt && !p.pendingDeletionAt) {
|
||||
recognizedPatient = { id: p.id, fullName: p.fullName };
|
||||
|
||||
if (!track.patientId) {
|
||||
// Привязываем трек к пациенту и переводим в MATCHED.
|
||||
await this.prisma.track.update({
|
||||
where: { id: opts.trackId },
|
||||
data: { patientId: p.id, status: 'MATCHED', lastSeenAt: now },
|
||||
});
|
||||
// Привязываем эмбеддинги трека к пациенту в face-service.
|
||||
fetch(`${faceServiceUrl}/enroll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ track_id: opts.trackId, patient_id: p.id }),
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) this.logger.warn(`face-service /enroll auto failed: ${r.status}`);
|
||||
else this.logger.log(`Auto-enrolled track ${opts.trackId} → patient ${p.id}`);
|
||||
})
|
||||
.catch((err) => this.logger.warn(`face-service /enroll auto error: ${err}`));
|
||||
} else {
|
||||
await this.prisma.track.update({
|
||||
where: { id: opts.trackId },
|
||||
data: { lastSeenAt: now },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this.prisma.track.update({
|
||||
where: { id: opts.trackId },
|
||||
data: { lastSeenAt: now },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this.prisma.track.update({
|
||||
where: { id: opts.trackId },
|
||||
data: { lastSeenAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
embedding: embedRes,
|
||||
faceDetected: embedRes !== null,
|
||||
evidenceKey,
|
||||
recognized: recognizedPatient
|
||||
? {
|
||||
patientId: recognizedPatient.id,
|
||||
fullName: recognizedPatient.fullName,
|
||||
confidence: recogRes?.confidence ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Jpeg(input: string): Buffer {
|
||||
// Убираем data:image/jpeg;base64, если есть.
|
||||
const stripped = input.includes(',') ? input.split(',', 2)[1]! : input;
|
||||
return Buffer.from(stripped, 'base64');
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: false });
|
||||
const config = app.get(ConfigService);
|
||||
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.enableCors({
|
||||
origin: config.get<string>('WEB_ADMIN_ORIGIN'),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const port = config.get<number>('API_PORT') ?? 4000;
|
||||
await app.listen(port);
|
||||
Logger.log(`reception-api listening on http://localhost:${port}`, 'Bootstrap');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface PolimedPatient {
|
||||
id: string;
|
||||
fullName: string;
|
||||
birthDate: string;
|
||||
phone: string;
|
||||
cardNumber: string;
|
||||
}
|
||||
|
||||
export interface PolimedAppointment {
|
||||
id: string;
|
||||
patientId: string;
|
||||
patientFullName: string;
|
||||
doctorFullName: string;
|
||||
specialty: string;
|
||||
scheduledFor: string;
|
||||
status: 'scheduled' | 'completed' | 'cancelled' | 'no_show';
|
||||
}
|
||||
|
||||
export type PolimedVisitEventType =
|
||||
| 'arrived'
|
||||
| 'service_started'
|
||||
| 'service_ended'
|
||||
| 'left_without_service';
|
||||
|
||||
@Injectable()
|
||||
export class PolimedClient {
|
||||
private readonly logger = new Logger(PolimedClient.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.baseUrl = config.getOrThrow<string>('POLIMED_BASE_URL');
|
||||
}
|
||||
|
||||
async searchPatients(query: string, limit = 20): Promise<PolimedPatient[]> {
|
||||
const url = new URL('/patients/search', this.baseUrl);
|
||||
url.searchParams.set('q', query);
|
||||
url.searchParams.set('limit', String(limit));
|
||||
return this.fetchJson(url);
|
||||
}
|
||||
|
||||
async getAppointments(date?: string): Promise<PolimedAppointment[]> {
|
||||
const url = new URL('/appointments', this.baseUrl);
|
||||
if (date) url.searchParams.set('date', date);
|
||||
return this.fetchJson(url);
|
||||
}
|
||||
|
||||
async getAppointment(id: string): Promise<PolimedAppointment> {
|
||||
const url = new URL(`/appointments/${id}`, this.baseUrl);
|
||||
return this.fetchJson(url);
|
||||
}
|
||||
|
||||
async pushVisitEvent(
|
||||
appointmentId: string,
|
||||
event: { type: PolimedVisitEventType; occurredAt: string; source?: string },
|
||||
): Promise<void> {
|
||||
const url = new URL(`/visits/${appointmentId}/events`, this.baseUrl);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...event, source: event.source ?? 'reception-video' }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`pushVisitEvent failed: ${res.status} ${url}`);
|
||||
throw new Error(`Polimed event failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchJson<T>(url: URL): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Polimed ${url.pathname} failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { Role } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { PolimedClient } from './polimed.client';
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller('polimed')
|
||||
export class PolimedController {
|
||||
constructor(private readonly polimed: PolimedClient) {}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@Get('patients/search')
|
||||
search(@Query('q') q = '', @Query('limit') limit = '20') {
|
||||
return this.polimed.searchPatients(q, Number(limit) || 20);
|
||||
}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@Get('appointments')
|
||||
appointments(@Query('date') date?: string) {
|
||||
return this.polimed.getAppointments(date);
|
||||
}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@Get('appointments/:id')
|
||||
appointment(@Param('id') id: string) {
|
||||
return this.polimed.getAppointment(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PolimedClient } from './polimed.client';
|
||||
import { PolimedController } from './polimed.controller';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [PolimedController],
|
||||
providers: [PolimedClient],
|
||||
exports: [PolimedClient],
|
||||
})
|
||||
export class PolimedModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { PrismaClient } from '@reception/db';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
this.logger.log('Connected to PostgreSQL');
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { IsISO8601, IsString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
|
||||
import { RecognitionService } from './recognition.service';
|
||||
|
||||
class ProbeDto {
|
||||
@IsString()
|
||||
frame!: string;
|
||||
|
||||
@IsString()
|
||||
cameraId!: string;
|
||||
|
||||
@IsISO8601()
|
||||
@Type(() => String)
|
||||
occurredAt!: string;
|
||||
}
|
||||
|
||||
@Controller('recognition')
|
||||
export class RecognitionController {
|
||||
constructor(private readonly recognition: RecognitionService) {}
|
||||
|
||||
// Public (нет JWT), но логируется как биометрический доступ.
|
||||
// В будущем заменим на внутренний service-to-service token.
|
||||
@Public()
|
||||
@LogsBiometry('recognition_probe')
|
||||
@Post('probe')
|
||||
probe(@Body() dto: ProbeDto) {
|
||||
return this.recognition.probe({
|
||||
frameBase64: dto.frame,
|
||||
cameraId: dto.cameraId,
|
||||
occurredAt: new Date(dto.occurredAt),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RecognitionController } from './recognition.controller';
|
||||
import { RecognitionService } from './recognition.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RecognitionController],
|
||||
providers: [RecognitionService],
|
||||
exports: [RecognitionService],
|
||||
})
|
||||
export class RecognitionModule {}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { FaceClient } from '../face/face.client';
|
||||
import { PolimedClient } from '../polimed/polimed.client';
|
||||
|
||||
interface ProbeOpts {
|
||||
frameBase64: string;
|
||||
cameraId: string;
|
||||
occurredAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RecognitionService {
|
||||
private readonly logger = new Logger(RecognitionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly face: FaceClient,
|
||||
private readonly polimed: PolimedClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Внутренний эндпоинт: вызывается fixtures-runner / video-ingest при появлении
|
||||
* лица. Идёт в face-service /recognize, при match создаёт Visit и
|
||||
* (опционально) подтягивает текущий appointment из Полимед.
|
||||
*
|
||||
* Возвращает информацию о созданном Visit или null если пациент не узнан.
|
||||
*/
|
||||
async probe(opts: ProbeOpts) {
|
||||
const match = await this.face.recognize(opts.frameBase64);
|
||||
if (!match) return null;
|
||||
|
||||
const patient = await this.prisma.patient.findUnique({ where: { id: match.patient_id } });
|
||||
if (!patient || patient.consentRevokedAt) {
|
||||
// Согласие отозвано — не создаём Visit, эмбеддинги скоро удалят.
|
||||
this.logger.warn(`Match patient_id=${match.patient_id} но пациент не найден / без согласия`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Подтягиваем appointments на сегодня и ищем подходящий по polimedPatientId.
|
||||
let polimedAppointmentId: string | null = null;
|
||||
if (patient.polimedPatientId) {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const appointments = await this.polimed.getAppointments(today);
|
||||
const ap = appointments.find((a) => a.patientId === patient.polimedPatientId);
|
||||
if (ap) polimedAppointmentId = ap.id;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Polimed lookup failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const visit = await this.prisma.visit.create({
|
||||
data: {
|
||||
patientId: patient.id,
|
||||
polimedAppointmentId,
|
||||
arrivedAt: opts.occurredAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (polimedAppointmentId) {
|
||||
this.polimed
|
||||
.pushVisitEvent(polimedAppointmentId, {
|
||||
type: 'arrived',
|
||||
occurredAt: opts.occurredAt.toISOString(),
|
||||
})
|
||||
.catch((err) => this.logger.warn(`Polimed pushVisitEvent failed: ${err}`));
|
||||
}
|
||||
|
||||
return {
|
||||
visitId: visit.id,
|
||||
patientId: patient.id,
|
||||
polimedAppointmentId,
|
||||
confidence: match.confidence,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { Role, TrackStatus } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { TracksService } from './tracks.service';
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller('tracks')
|
||||
export class TracksController {
|
||||
constructor(private readonly tracks: TracksService) {}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@Get()
|
||||
list(
|
||||
@Query('status') status?: TrackStatus,
|
||||
@Query('shiftDate') shiftDate?: string,
|
||||
) {
|
||||
return this.tracks.list({ status, shiftDate });
|
||||
}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@LogsBiometry('view_track')
|
||||
@Get(':id')
|
||||
getOne(@Param('id') id: string) {
|
||||
return this.tracks.getOne(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TracksController } from './tracks.controller';
|
||||
import { TracksService } from './tracks.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TracksController],
|
||||
providers: [TracksService],
|
||||
exports: [TracksService],
|
||||
})
|
||||
export class TracksModule {}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { TrackStatus } from '@reception/db';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EvidenceService } from '../evidence/evidence.service';
|
||||
|
||||
interface ListTracksOpts {
|
||||
status?: TrackStatus;
|
||||
shiftDate?: string; // YYYY-MM-DD; если задан — firstSeenAt в этом дне
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TracksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly evidence: EvidenceService,
|
||||
) {}
|
||||
|
||||
async list(opts: ListTracksOpts) {
|
||||
const where: Parameters<PrismaService['track']['findMany']>[0] extends infer T
|
||||
? T extends { where?: infer W }
|
||||
? W
|
||||
: never
|
||||
: never = {};
|
||||
|
||||
if (opts.status) (where as { status?: TrackStatus }).status = opts.status;
|
||||
|
||||
if (opts.shiftDate) {
|
||||
const from = new Date(opts.shiftDate);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
const to = new Date(opts.shiftDate);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
(where as { firstSeenAt?: object }).firstSeenAt = { gte: from, lte: to };
|
||||
}
|
||||
|
||||
const tracks = await this.prisma.track.findMany({
|
||||
where,
|
||||
orderBy: { firstSeenAt: 'desc' },
|
||||
include: {
|
||||
events: { orderBy: { occurredAt: 'asc' } },
|
||||
_count: { select: { faceEmbeddings: true } },
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
tracks.map(async (t) => ({
|
||||
id: t.id,
|
||||
status: t.status,
|
||||
firstSeenAt: t.firstSeenAt,
|
||||
lastSeenAt: t.lastSeenAt,
|
||||
patientId: t.patientId,
|
||||
embeddingsCount: t._count.faceEmbeddings,
|
||||
zonesPath: Array.from(new Set(t.events.map((e) => e.zoneId))),
|
||||
events: t.events.map((e) => ({
|
||||
type: e.type,
|
||||
cameraId: e.cameraId,
|
||||
zoneId: e.zoneId,
|
||||
occurredAt: e.occurredAt,
|
||||
evidenceKey: e.evidenceKey,
|
||||
})),
|
||||
thumbnailUrl: await this.firstEvidenceUrl(t.events),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async getOne(id: string) {
|
||||
const track = await this.prisma.track.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
events: { orderBy: { occurredAt: 'asc' }, include: { zone: true, camera: true } },
|
||||
patient: true,
|
||||
},
|
||||
});
|
||||
if (!track) throw new NotFoundException(`Track ${id} not found`);
|
||||
|
||||
const eventsWithUrls = await Promise.all(
|
||||
track.events.map(async (e) => ({
|
||||
type: e.type,
|
||||
cameraId: e.cameraId,
|
||||
cameraName: e.camera.name,
|
||||
zoneCode: e.zone.code,
|
||||
occurredAt: e.occurredAt,
|
||||
evidenceKey: e.evidenceKey,
|
||||
evidenceUrl: e.evidenceKey ? await this.evidence.getPresignedUrl(e.evidenceKey) : null,
|
||||
faceBbox: e.faceBbox as { box: number[]; imgW: number; imgH: number } | null,
|
||||
})),
|
||||
);
|
||||
|
||||
const consistency = await this.computeEmbeddingConsistency(id);
|
||||
|
||||
return {
|
||||
id: track.id,
|
||||
status: track.status,
|
||||
firstSeenAt: track.firstSeenAt,
|
||||
lastSeenAt: track.lastSeenAt,
|
||||
patient: track.patient,
|
||||
events: eventsWithUrls,
|
||||
consistency,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Считает попарные cos-дистанции между всеми эмбеддингами трека (через pgvector `<=>`).
|
||||
* Возвращает min/max/avg + статус «один ли это человек».
|
||||
*
|
||||
* Пороги (эмпирические для InsightFace buffalo_l):
|
||||
* max ≤ 0.40 — definitely_same: один человек, ракурсы похожи.
|
||||
* 0.40 < max ≤ 0.55 — likely_same: один человек, но сильная вариация (поворот головы, очки).
|
||||
* max > 0.55 — suspicious: возможно, разные лица.
|
||||
*
|
||||
* REID_THRESHOLD (0.35) для consistency не подходит — он строже, рассчитан на склейку
|
||||
* РАЗНЫХ треков между камерами, а не на внутреннюю когерентность одного трека.
|
||||
*/
|
||||
private async computeEmbeddingConsistency(trackId: string): Promise<{
|
||||
count: number;
|
||||
pairs: number;
|
||||
minDistance: number | null;
|
||||
maxDistance: number | null;
|
||||
avgDistance: number | null;
|
||||
status: 'definitely_same' | 'likely_same' | 'suspicious';
|
||||
isCoherent: boolean;
|
||||
}> {
|
||||
const [{ count }] = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*)::bigint AS count
|
||||
FROM face_embeddings
|
||||
WHERE track_id = ${trackId}::uuid
|
||||
`;
|
||||
const n = Number(count);
|
||||
|
||||
if (n < 2) {
|
||||
return {
|
||||
count: n,
|
||||
pairs: 0,
|
||||
minDistance: null,
|
||||
maxDistance: null,
|
||||
avgDistance: null,
|
||||
status: 'definitely_same',
|
||||
isCoherent: true,
|
||||
};
|
||||
}
|
||||
|
||||
const [stats] = await this.prisma.$queryRaw<
|
||||
Array<{ min_d: number; max_d: number; avg_d: number; pairs: bigint }>
|
||||
>`
|
||||
SELECT
|
||||
MIN(d)::float AS min_d,
|
||||
MAX(d)::float AS max_d,
|
||||
AVG(d)::float AS avg_d,
|
||||
COUNT(*)::bigint AS pairs
|
||||
FROM (
|
||||
SELECT (a.embedding <=> b.embedding) AS d
|
||||
FROM face_embeddings a
|
||||
JOIN face_embeddings b ON a.id < b.id
|
||||
WHERE a.track_id = ${trackId}::uuid AND b.track_id = ${trackId}::uuid
|
||||
) p
|
||||
`;
|
||||
|
||||
const maxD = stats?.max_d ?? 1;
|
||||
const status: 'definitely_same' | 'likely_same' | 'suspicious' =
|
||||
maxD <= 0.4 ? 'definitely_same' : maxD <= 0.55 ? 'likely_same' : 'suspicious';
|
||||
|
||||
return {
|
||||
count: n,
|
||||
pairs: Number(stats?.pairs ?? 0),
|
||||
minDistance: stats?.min_d ?? null,
|
||||
maxDistance: maxD,
|
||||
avgDistance: stats?.avg_d ?? null,
|
||||
status,
|
||||
isCoherent: status !== 'suspicious',
|
||||
};
|
||||
}
|
||||
|
||||
private async firstEvidenceUrl(events: Array<{ evidenceKey: string | null }>): Promise<string | null> {
|
||||
const first = events.find((e) => e.evidenceKey);
|
||||
return first?.evidenceKey ? this.evidence.getPresignedUrl(first.evidenceKey) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { Role } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { VisitsService } from './visits.service';
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller()
|
||||
export class VisitsController {
|
||||
constructor(private readonly visits: VisitsService) {}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@Get('patients')
|
||||
listPatients() {
|
||||
return this.visits.listPatients();
|
||||
}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@LogsBiometry('view_patient_visits')
|
||||
@Get('patients/:patientId/visits')
|
||||
listVisits(@Param('patientId') patientId: string) {
|
||||
return this.visits.listForPatient(patientId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { VisitsController } from './visits.controller';
|
||||
import { VisitsService } from './visits.service';
|
||||
|
||||
@Module({
|
||||
controllers: [VisitsController],
|
||||
providers: [VisitsService],
|
||||
})
|
||||
export class VisitsModule {}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EvidenceService } from '../evidence/evidence.service';
|
||||
|
||||
export interface JourneySegment {
|
||||
zoneCode: string;
|
||||
cameraName: string;
|
||||
startedAt: Date;
|
||||
endedAt: Date;
|
||||
eventTypes: string[];
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VisitsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly evidence: EvidenceService,
|
||||
) {}
|
||||
|
||||
async listForPatient(patientId: string) {
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id: patientId },
|
||||
include: { visits: { orderBy: { arrivedAt: 'desc' } } },
|
||||
});
|
||||
if (!patient) throw new NotFoundException(`Patient ${patientId} not found`);
|
||||
|
||||
const [avatar, journey] = await Promise.all([
|
||||
this.getPatientAvatar(patientId),
|
||||
this.getPatientJourney(patientId),
|
||||
]);
|
||||
|
||||
return {
|
||||
patient: {
|
||||
id: patient.id,
|
||||
fullName: patient.fullName,
|
||||
polimedPatientId: patient.polimedPatientId,
|
||||
consentReceivedAt: patient.consentReceivedAt,
|
||||
consentRevokedAt: patient.consentRevokedAt,
|
||||
pendingDeletionAt: patient.pendingDeletionAt,
|
||||
avatarUrl: avatar?.url ?? null,
|
||||
avatarBbox: avatar?.bbox ?? null,
|
||||
},
|
||||
journey,
|
||||
visits: patient.visits.map((v) => ({
|
||||
id: v.id,
|
||||
arrivedAt: v.arrivedAt,
|
||||
serviceStartedAt: v.serviceStartedAt,
|
||||
serviceEndedAt: v.serviceEndedAt,
|
||||
leftWithoutService: v.leftWithoutService,
|
||||
polimedAppointmentId: v.polimedAppointmentId,
|
||||
waitingSec:
|
||||
v.serviceStartedAt && v.arrivedAt
|
||||
? Math.round((v.serviceStartedAt.getTime() - v.arrivedAt.getTime()) / 1000)
|
||||
: null,
|
||||
serviceSec:
|
||||
v.serviceEndedAt && v.serviceStartedAt
|
||||
? Math.round((v.serviceEndedAt.getTime() - v.serviceStartedAt.getTime()) / 1000)
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async listPatients() {
|
||||
const patients = await this.prisma.patient.findMany({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: { _count: { select: { visits: true } } },
|
||||
});
|
||||
|
||||
const rows = await Promise.all(
|
||||
patients.map(async (p) => {
|
||||
const avatar = await this.getPatientAvatar(p.id);
|
||||
return {
|
||||
id: p.id,
|
||||
fullName: p.fullName,
|
||||
polimedPatientId: p.polimedPatientId,
|
||||
consentReceivedAt: p.consentReceivedAt,
|
||||
consentRevokedAt: p.consentRevokedAt,
|
||||
pendingDeletionAt: p.pendingDeletionAt,
|
||||
visitsCount: p._count.visits,
|
||||
avatarUrl: avatar?.url ?? null,
|
||||
avatarBbox: avatar?.bbox ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Маршрут пациента: события всех его треков, сгруппированные по непрерывным
|
||||
* пребываниям в одной зоне. Считаем длительность каждого сегмента и суммы по зонам.
|
||||
* Эвристика "потерян": последний сегмент в зоне D, прошло >15 мин, нет последующих событий.
|
||||
*/
|
||||
private async getPatientJourney(patientId: string) {
|
||||
const events = await this.prisma.$queryRaw<
|
||||
Array<{
|
||||
track_id: string;
|
||||
type: string;
|
||||
occurred_at: Date;
|
||||
zone_code: string;
|
||||
camera_name: string;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
te.track_id::text AS track_id,
|
||||
te.type::text AS type,
|
||||
te.occurred_at,
|
||||
z.code::text AS zone_code,
|
||||
c.name AS camera_name
|
||||
FROM track_events te
|
||||
JOIN tracks t ON t.id = te.track_id
|
||||
JOIN zones z ON z.id = te.zone_id
|
||||
JOIN cameras c ON c.id = te.camera_id
|
||||
WHERE t.patient_id = ${patientId}::uuid
|
||||
ORDER BY te.occurred_at ASC
|
||||
`;
|
||||
|
||||
const segments: JourneySegment[] = [];
|
||||
for (const e of events) {
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.zoneCode === e.zone_code && last.cameraName === e.camera_name) {
|
||||
last.endedAt = e.occurred_at;
|
||||
last.eventTypes.push(e.type);
|
||||
last.durationSec = Math.round((last.endedAt.getTime() - last.startedAt.getTime()) / 1000);
|
||||
} else {
|
||||
segments.push({
|
||||
zoneCode: e.zone_code,
|
||||
cameraName: e.camera_name,
|
||||
startedAt: e.occurred_at,
|
||||
endedAt: e.occurred_at,
|
||||
eventTypes: [e.type],
|
||||
durationSec: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const byZone: Record<string, number> = {};
|
||||
for (const s of segments) {
|
||||
byZone[s.zoneCode] = (byZone[s.zoneCode] ?? 0) + s.durationSec;
|
||||
}
|
||||
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
let lostInTransit = false;
|
||||
if (lastSegment && lastSegment.zoneCode === 'D') {
|
||||
const ageMs = Date.now() - lastSegment.endedAt.getTime();
|
||||
if (ageMs > 15 * 60 * 1000) lostInTransit = true;
|
||||
}
|
||||
|
||||
return {
|
||||
segments,
|
||||
timeInZoneSec: byZone,
|
||||
lostInTransit,
|
||||
totalEvents: events.length,
|
||||
firstSeenAt: events[0]?.occurred_at ?? null,
|
||||
lastSeenAt: events[events.length - 1]?.occurred_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Аватар пациента — лучший кадр из одного из его треков.
|
||||
* Предпочитаем кадры с face_bbox (детектированное лицо).
|
||||
*/
|
||||
private async getPatientAvatar(
|
||||
patientId: string,
|
||||
): Promise<{ url: string; bbox: { box: number[]; imgW: number; imgH: number } | null } | null> {
|
||||
const rows = await this.prisma.$queryRaw<
|
||||
Array<{ evidence_key: string; face_bbox: unknown }>
|
||||
>`
|
||||
SELECT te.evidence_key, te.face_bbox
|
||||
FROM track_events te
|
||||
JOIN tracks t ON t.id = te.track_id
|
||||
WHERE t.patient_id = ${patientId}::uuid
|
||||
AND te.evidence_key IS NOT NULL
|
||||
ORDER BY (te.face_bbox IS NULL) ASC, te.occurred_at ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
|
||||
const url = await this.evidence.getPresignedUrl(row.evidence_key);
|
||||
return {
|
||||
url,
|
||||
bbox:
|
||||
(row.face_bbox as { box: number[]; imgW: number; imgH: number } | null) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
describe('Auth (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const SENIOR_EMAIL = 'senior@local';
|
||||
const SENIOR_PASSWORD = process.env.SEED_PASSWORD_SENIOR ?? 'senior123';
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
await app.init();
|
||||
prisma = app.get(PrismaService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /health is public', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('GET /auth/me requires auth', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/auth/me');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /auth/login returns access cookie and userId', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.userId).toBeDefined();
|
||||
const cookies = (res.headers['set-cookie'] as unknown as string[]) ?? [];
|
||||
expect(cookies.some((c) => c.startsWith('access_token='))).toBe(true);
|
||||
expect(cookies.some((c) => c.startsWith('refresh_token='))).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /auth/me with cookie returns user + role', async () => {
|
||||
const loginRes = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
|
||||
const cookies = (loginRes.headers['set-cookie'] as unknown as string[]) ?? [];
|
||||
|
||||
const meRes = await request(app.getHttpServer())
|
||||
.get('/auth/me')
|
||||
.set('Cookie', cookies);
|
||||
|
||||
expect(meRes.status).toBe(200);
|
||||
expect(meRes.body.email).toBe(SENIOR_EMAIL);
|
||||
expect(meRes.body.role).toBe('SENIOR_ADMIN');
|
||||
});
|
||||
|
||||
it('POST /auth/login with bad password → 401', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: SENIOR_EMAIL, password: 'wrong-password' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /auth/logout clears tokens', async () => {
|
||||
const loginRes = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
|
||||
const cookies = (loginRes.headers['set-cookie'] as unknown as string[]) ?? [];
|
||||
|
||||
const logoutRes = await request(app.getHttpServer())
|
||||
.post('/auth/logout')
|
||||
.set('Cookie', cookies);
|
||||
|
||||
expect(logoutRes.status).toBe(200);
|
||||
|
||||
// Используем тот же refresh — должен быть отозван.
|
||||
const refreshCookie = cookies.find((c) => c.startsWith('refresh_token='));
|
||||
expect(refreshCookie).toBeDefined();
|
||||
|
||||
const refreshRes = await request(app.getHttpServer())
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', refreshCookie!);
|
||||
|
||||
expect(refreshRes.status).toBe(401);
|
||||
});
|
||||
|
||||
describe('refresh token rotation', () => {
|
||||
it('issues new tokens with valid refresh', async () => {
|
||||
const loginRes = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
|
||||
const cookies = (loginRes.headers['set-cookie'] as unknown as string[]) ?? [];
|
||||
const refreshCookie = cookies.find((c) => c.startsWith('refresh_token='))!;
|
||||
|
||||
const refreshRes = await request(app.getHttpServer())
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', refreshCookie);
|
||||
|
||||
expect(refreshRes.status).toBe(200);
|
||||
const newCookies = (refreshRes.headers['set-cookie'] as unknown as string[]) ?? [];
|
||||
expect(newCookies.some((c) => c.startsWith('access_token='))).toBe(true);
|
||||
|
||||
// Старый refresh теперь невалиден — должен быть отозван.
|
||||
const reuseRes = await request(app.getHttpServer())
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', refreshCookie);
|
||||
expect(reuseRes.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* M6 acceptance test:
|
||||
* создаём фикстуру (track + embedding) →
|
||||
* POST /enrollment →
|
||||
* POST /consents/.../revoke с CONSENT_REVOKE_DELAY_MS=300 →
|
||||
* через ~600мс эмбеддинги удалены, трек ANONYMIZED, ФИО null,
|
||||
* в biometry_access_log полный след.
|
||||
*
|
||||
* FaceClient и PolimedClient замоканы. Redis/Postgres — реальные.
|
||||
*/
|
||||
// CONSENT_REVOKE_DELAY_MS=300 устанавливается в test/setup-env.ts ДО imports.
|
||||
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { TrackStatus } from '@reception/db';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import request from 'supertest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { FaceClient } from '../src/face/face.client';
|
||||
import { PolimedClient } from '../src/polimed/polimed.client';
|
||||
|
||||
const SENIOR_EMAIL = 'senior@local';
|
||||
const SENIOR_PASSWORD = process.env.SEED_PASSWORD_SENIOR ?? 'senior123';
|
||||
|
||||
class FaceClientMock {
|
||||
enrolledTracks: string[] = [];
|
||||
deletedPatients: string[] = [];
|
||||
countCalls = 0;
|
||||
|
||||
async recognize() {
|
||||
return null;
|
||||
}
|
||||
async enrollTrack(trackId: string, _patientId: string): Promise<number> {
|
||||
this.enrolledTracks.push(trackId);
|
||||
return 1;
|
||||
}
|
||||
async deletePatientEmbeddings(patientId: string): Promise<number> {
|
||||
this.deletedPatients.push(patientId);
|
||||
return 1;
|
||||
}
|
||||
async countPatientEmbeddings(_patientId: string): Promise<number> {
|
||||
this.countCalls += 1;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
class PolimedClientMock {
|
||||
async searchPatients(q: string) {
|
||||
return [
|
||||
{ id: q, fullName: 'Иванов Иван Иванович', birthDate: '1980-01-01', phone: '+7900', cardNumber: 'K-MOCK' },
|
||||
];
|
||||
}
|
||||
async getAppointments() {
|
||||
return [];
|
||||
}
|
||||
async getAppointment(id: string) {
|
||||
return {
|
||||
id,
|
||||
patientId: 'pol-p-test',
|
||||
patientFullName: 'Иванов Иван Иванович',
|
||||
doctorFullName: 'Доктор Тест',
|
||||
specialty: 'Терапевт',
|
||||
scheduledFor: new Date().toISOString(),
|
||||
status: 'scheduled' as const,
|
||||
};
|
||||
}
|
||||
async pushVisitEvent() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Enrollment + Consent revoke (e2e, M6)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
let face: FaceClientMock;
|
||||
|
||||
let cameraId: string;
|
||||
let zoneId: string;
|
||||
let trackId: string;
|
||||
let embeddingId: string;
|
||||
const polimedPatientId = `pol-p-test-${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(FaceClient)
|
||||
.useClass(FaceClientMock)
|
||||
.overrideProvider(PolimedClient)
|
||||
.useClass(PolimedClientMock)
|
||||
.compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
await app.init();
|
||||
|
||||
prisma = app.get(PrismaService);
|
||||
face = app.get(FaceClient) as unknown as FaceClientMock;
|
||||
|
||||
// Готовим зону, камеру, трек, фейковый эмбеддинг.
|
||||
const zone = await prisma.zone.findUniqueOrThrow({ where: { code: 'A' } });
|
||||
zoneId = zone.id;
|
||||
const camera = await prisma.camera.create({
|
||||
data: { name: `test-cam-${Date.now()}`, zoneId },
|
||||
});
|
||||
cameraId = camera.id;
|
||||
trackId = randomUUID();
|
||||
embeddingId = randomUUID();
|
||||
await prisma.track.create({
|
||||
data: {
|
||||
id: trackId,
|
||||
firstSeenAt: new Date(Date.now() - 60_000),
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Прямой INSERT эмбеддинга через $executeRawUnsafe (синтетический 512-d).
|
||||
const embeddingLiteral = '[' + Array.from({ length: 512 }, () => Math.random()).join(',') + ']';
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO face_embeddings (id, embedding, track_id, camera_id, quality, captured_at, created_at)
|
||||
VALUES ($1::uuid, $2::vector, $3::uuid, $4::uuid, $5, NOW(), NOW())`,
|
||||
embeddingId,
|
||||
embeddingLiteral,
|
||||
trackId,
|
||||
cameraId,
|
||||
0.9,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM face_embeddings WHERE track_id = $1::uuid`, trackId);
|
||||
await prisma.visit.deleteMany({ where: { patient: { polimedPatientId } } });
|
||||
await prisma.patientConsent.deleteMany({ where: { patient: { polimedPatientId } } });
|
||||
await prisma.consentRevocationJob.deleteMany({ where: { patient: { polimedPatientId } } });
|
||||
await prisma.biometryAccessLog.deleteMany({ where: { subjectPatientId: { not: null } } });
|
||||
await prisma.track.deleteMany({ where: { id: trackId } });
|
||||
await prisma.patient.deleteMany({ where: { polimedPatientId } });
|
||||
await prisma.camera.deleteMany({ where: { id: cameraId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function loginSenior(): Promise<string[]> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
return (res.headers['set-cookie'] as unknown as string[]) ?? [];
|
||||
}
|
||||
|
||||
it('full enrollment + revoke flow', async () => {
|
||||
const cookies = await loginSenior();
|
||||
|
||||
// 1. Enrollment.
|
||||
const enrollRes = await request(app.getHttpServer())
|
||||
.post('/enrollment')
|
||||
.set('Cookie', cookies)
|
||||
.send({
|
||||
trackId,
|
||||
polimedPatientId,
|
||||
polimedAppointmentId: 'pol-a-test',
|
||||
paperConsentRef: 'paper-ref-001',
|
||||
});
|
||||
|
||||
expect(enrollRes.status).toBe(201);
|
||||
expect(enrollRes.body.patientId).toBeDefined();
|
||||
expect(enrollRes.body.visitId).toBeDefined();
|
||||
const patientId: string = enrollRes.body.patientId;
|
||||
|
||||
// FaceClient.enrollTrack был вызван.
|
||||
expect(face.enrolledTracks).toContain(trackId);
|
||||
|
||||
// Проверяем DB-состояние после enrollment.
|
||||
const patientAfterEnroll = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } });
|
||||
expect(patientAfterEnroll.polimedPatientId).toBe(polimedPatientId);
|
||||
expect(patientAfterEnroll.consentReceivedAt).not.toBeNull();
|
||||
|
||||
const trackAfterEnroll = await prisma.track.findUniqueOrThrow({ where: { id: trackId } });
|
||||
expect(trackAfterEnroll.patientId).toBe(patientId);
|
||||
expect(trackAfterEnroll.status).toBe(TrackStatus.MATCHED);
|
||||
|
||||
const grantedConsent = await prisma.patientConsent.findFirst({
|
||||
where: { patientId, action: 'GRANTED' },
|
||||
});
|
||||
expect(grantedConsent).not.toBeNull();
|
||||
expect(grantedConsent?.paperRef).toBe('paper-ref-001');
|
||||
|
||||
// 2. Revoke consent.
|
||||
const revokeRes = await request(app.getHttpServer())
|
||||
.post(`/consents/${patientId}/revoke`)
|
||||
.set('Cookie', cookies);
|
||||
expect(revokeRes.status).toBe(202);
|
||||
expect(revokeRes.body.jobId).toBeDefined();
|
||||
|
||||
// Сразу после revoke — consentRevokedAt и pendingDeletionAt установлены.
|
||||
const patientAfterRevoke = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } });
|
||||
expect(patientAfterRevoke.consentRevokedAt).not.toBeNull();
|
||||
expect(patientAfterRevoke.pendingDeletionAt).not.toBeNull();
|
||||
|
||||
// 3. Ждём срабатывания BullMQ (delay=300мс + обработка).
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
// 4. Проверяем результат отложенной задачи.
|
||||
expect(face.deletedPatients).toContain(patientId);
|
||||
|
||||
const patientFinal = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } });
|
||||
expect(patientFinal.fullName).toBeNull();
|
||||
expect(patientFinal.pendingDeletionAt).toBeNull();
|
||||
|
||||
const trackFinal = await prisma.track.findUniqueOrThrow({ where: { id: trackId } });
|
||||
expect(trackFinal.status).toBe(TrackStatus.ANONYMIZED);
|
||||
|
||||
const job = await prisma.consentRevocationJob.findFirstOrThrow({ where: { patientId } });
|
||||
expect(job.status).toBe('DONE');
|
||||
expect(job.completedAt).not.toBeNull();
|
||||
|
||||
// 5. Полный аудит-след в biometry_access_log.
|
||||
const logs = await prisma.biometryAccessLog.findMany({
|
||||
where: { subjectPatientId: patientId },
|
||||
orderBy: { occurredAt: 'asc' },
|
||||
});
|
||||
const actions = logs.map((l) => l.action);
|
||||
expect(actions).toContain('enroll');
|
||||
expect(actions).toContain('consent_revoke');
|
||||
expect(actions).toContain('consent_revocation_completed');
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { config as dotenv } from 'dotenv';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Загружаем корневой .env для e2e-тестов.
|
||||
dotenv({ path: join(__dirname, '..', '..', '..', '.env') });
|
||||
|
||||
// В e2e ускоряем delay очереди до 300мс (иначе тест M6 не дождётся 24 ч).
|
||||
// override=true потому что .env может уже содержать прод-значение.
|
||||
process.env.CONSENT_REVOKE_DELAY_MS = '300';
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.e2e-spec.ts"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@reception/tsconfig/nest.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user