Initial commit: digital reception monorepo (M1-M11 + demo extensions)

This commit is contained in:
2026-05-25 12:59:54 +05:00
commit b9f88194d9
182 changed files with 20578 additions and 0 deletions
+67
View File
@@ -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 {}
+38
View File
@@ -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,
});
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AuditController } from './audit.controller';
@Module({
controllers: [AuditController],
})
export class AuditModule {}
+92
View File
@@ -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',
});
}
}
+27
View File
@@ -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 {}
+106
View File
@@ -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);
}
}
+26
View File
@@ -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;
}
}
+9
View File
@@ -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;
+1
View File
@@ -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,
}));
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { CamerasController } from './cameras.controller';
@Module({
controllers: [CamerasController],
})
export class CamerasModule {}
+43
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+60
View File
@@ -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 {}
+198
View File
@@ -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,
};
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { EvidenceService } from './evidence.service';
@Global()
@Module({
providers: [EvidenceService],
exports: [EvidenceService],
})
export class EvidenceModule {}
+65
View File
@@ -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 });
}
}
+64
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { FaceClient } from './face.client';
@Global()
@Module({
providers: [FaceClient],
exports: [FaceClient],
})
export class FaceModule {}
+11
View File
@@ -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' };
}
}
+83
View File
@@ -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,
});
}
}
+10
View File
@@ -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 {}
+236
View File
@@ -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');
}
+24
View File
@@ -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();
+78
View File
@@ -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);
}
}
+11
View File
@@ -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 {}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -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,
};
}
}
+28
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+177
View File
@@ -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;
}
}
+25
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+189
View File
@@ -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,
};
}
}