Skip to content

Commit 8f7aba6

Browse files
authored
Merge pull request #241 from mxllv/feat/define-verificationrecord
Feat/define verificationrecord
2 parents 231c72a + dca53f5 commit 8f7aba6

11 files changed

Lines changed: 429 additions & 0 deletions

backend/src/app.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
44
import { AppController } from './app.controller';
55
import { AppService } from './app.service';
66
import { AuthModule } from './auth/auth.module';
7+
import { DocumentsModule } from './documents/documents.module';
8+
import { RiskAssessmentModule } from './risk-assessment/risk-assessment.module';
79
import { UsersModule } from './users/users.module';
10+
import { VerificationModule } from './verification/verification.module';
811

912
@Module({
1013
imports: [
@@ -28,6 +31,9 @@ import { UsersModule } from './users/users.module';
2831
}),
2932
UsersModule,
3033
AuthModule,
34+
DocumentsModule,
35+
RiskAssessmentModule,
36+
VerificationModule,
3137
],
3238
controllers: [AppController],
3339
providers: [AppService],
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {
2+
BadRequestException,
3+
Controller,
4+
Post,
5+
Req,
6+
Res,
7+
UploadedFile,
8+
UseGuards,
9+
UseInterceptors,
10+
} from '@nestjs/common';
11+
import { FileInterceptor } from '@nestjs/platform-express';
12+
import { ConfigService } from '@nestjs/config';
13+
import { Request, Response } from 'express';
14+
import { createHash } from 'crypto';
15+
import { promises as fs } from 'fs';
16+
import { extname, join } from 'path';
17+
import * as multer from 'multer';
18+
19+
import { DocumentsService } from './documents.service';
20+
import { DocumentStatus } from './entities/document.entity';
21+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
22+
import { User } from '../users/entities/user.entity';
23+
24+
const ALLOWED_MIME_TYPES = ['application/pdf', 'image/png', 'image/jpeg'];
25+
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024;
26+
27+
const multerStorage = multer.memoryStorage();
28+
29+
const fileFilter: multer.FileFilterCallback = (_req, file, callback) => {
30+
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
31+
return callback(null, true);
32+
}
33+
34+
return callback(new BadRequestException('Only PDF, PNG, or JPEG files are allowed'), false);
35+
};
36+
37+
@Controller('documents')
38+
export class DocumentsController {
39+
constructor(
40+
private readonly documentsService: DocumentsService,
41+
private readonly configService: ConfigService,
42+
) {}
43+
44+
@Post('upload')
45+
@UseGuards(JwtAuthGuard)
46+
@UseInterceptors(
47+
FileInterceptor('file', {
48+
storage: multerStorage,
49+
fileFilter,
50+
limits: { fileSize: MAX_FILE_SIZE_BYTES },
51+
}),
52+
)
53+
async uploadDocument(
54+
@UploadedFile() file: Express.Multer.File,
55+
@Req() req: Request & { user?: User },
56+
@Res() res: Response,
57+
) {
58+
if (!file) {
59+
throw new BadRequestException('File is required');
60+
}
61+
62+
const user = req.user;
63+
if (!user) {
64+
throw new BadRequestException('Authenticated user is required');
65+
}
66+
67+
const fileHash = createHash('sha256').update(file.buffer).digest('hex');
68+
const existing = await this.documentsService.findByFileHash(fileHash);
69+
if (existing) {
70+
return res.status(200).send(existing);
71+
}
72+
73+
const uploadDir = this.configService.get<string>('UPLOAD_DIR') || './uploads';
74+
await fs.mkdir(uploadDir, { recursive: true });
75+
76+
const extension = extname(file.originalname) || '';
77+
const filename = ${fileHash};
78+
const targetPath = join(uploadDir, filename);
79+
await fs.writeFile(targetPath, file.buffer);
80+
81+
const document = await this.documentsService.create({
82+
ownerId: user.id,
83+
title: file.originalname,
84+
filePath: targetPath,
85+
fileHash,
86+
fileSize: file.size,
87+
mimeType: file.mimetype,
88+
status: DocumentStatus.PENDING,
89+
});
90+
91+
return res.status(201).send(document);
92+
}
93+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Module } from '@nestjs/common';
2+
import { ConfigModule } from '@nestjs/config';
3+
import { TypeOrmModule } from '@nestjs/typeorm';
4+
import { DocumentsController } from './documents.controller';
5+
import { DocumentsService } from './documents.service';
6+
import { Document } from './entities/document.entity';
7+
8+
@Module({
9+
imports: [ConfigModule, TypeOrmModule.forFeature([Document])],
10+
controllers: [DocumentsController],
11+
providers: [DocumentsService],
12+
exports: [DocumentsService],
13+
})
14+
export class DocumentsModule {}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { Document, DocumentStatus } from './entities/document.entity';
5+
6+
@Injectable()
7+
export class DocumentsService {
8+
constructor(
9+
@InjectRepository(Document)
10+
private readonly documentRepository: Repository<Document>,
11+
) {}
12+
13+
create(payload: Partial<Document>): Promise<Document> {
14+
const document = this.documentRepository.create(payload);
15+
return this.documentRepository.save(document);
16+
}
17+
18+
findById(id: string): Promise<Document | null> {
19+
return this.documentRepository.findOne({ where: { id } });
20+
}
21+
22+
findByOwner(ownerId: string): Promise<Document[]> {
23+
return this.documentRepository.find({ where: { ownerId } });
24+
}
25+
26+
findByFileHash(fileHash: string): Promise<Document | null> {
27+
return this.documentRepository.findOne({ where: { fileHash } });
28+
}
29+
30+
async updateStatus(id: string, status: DocumentStatus): Promise<Document | null> {
31+
await this.documentRepository.update(id, { status });
32+
return this.findById(id);
33+
}
34+
35+
async updateRisk(
36+
id: string,
37+
score: number,
38+
flags: string[],
39+
): Promise<Document | null> {
40+
await this.documentRepository.update(id, {
41+
riskScore: score,
42+
riskFlags: flags,
43+
});
44+
return this.findById(id);
45+
}
46+
47+
async delete(id: string): Promise<void> {
48+
await this.documentRepository.delete(id);
49+
}
50+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
Column,
3+
CreateDateColumn,
4+
Entity,
5+
Index,
6+
ManyToOne,
7+
PrimaryGeneratedColumn,
8+
UpdateDateColumn,
9+
} from 'typeorm';
10+
import { User } from '../../users/entities/user.entity';
11+
12+
export enum DocumentStatus {
13+
PENDING = 'pending',
14+
ANALYZING = 'analyzing',
15+
VERIFIED = 'verified',
16+
FLAGGED = 'flagged',
17+
REJECTED = 'rejected',
18+
}
19+
20+
@Entity('documents')
21+
@Index('IDX_DOCUMENT_FILE_HASH', ['fileHash'], { unique: true })
22+
export class Document {
23+
@PrimaryGeneratedColumn('uuid')
24+
id: string;
25+
26+
@Column({ name: 'owner_id' })
27+
ownerId: string;
28+
29+
@ManyToOne(() => User, { onDelete: 'CASCADE' })
30+
owner: User;
31+
32+
@Column()
33+
title: string;
34+
35+
@Column({ name: 'file_path' })
36+
filePath: string;
37+
38+
@Column({ name: 'file_hash' })
39+
fileHash: string;
40+
41+
@Column({ name: 'file_size', type: 'int' })
42+
fileSize: number;
43+
44+
@Column({ name: 'mime_type' })
45+
mimeType: string;
46+
47+
@Column({
48+
type: 'enum',
49+
enum: DocumentStatus,
50+
default: DocumentStatus.PENDING,
51+
})
52+
status: DocumentStatus;
53+
54+
@Column({ name: 'risk_score', type: 'float', nullable: true })
55+
riskScore?: number;
56+
57+
@Column({ name: 'risk_flags', type: 'json', nullable: true })
58+
riskFlags?: string[];
59+
60+
@CreateDateColumn({ name: 'created_at' })
61+
createdAt: Date;
62+
63+
@UpdateDateColumn({ name: 'updated_at' })
64+
updatedAt: Date;
65+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
2+
3+
import { RiskAssessmentService } from './risk-assessment.service';
4+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
5+
6+
@Controller('documents')
7+
export class RiskAssessmentController {
8+
constructor(private readonly riskService: RiskAssessmentService) {}
9+
10+
@Get(':id/risk')
11+
@UseGuards(JwtAuthGuard)
12+
async getRisk(@Param('id') id: string) {
13+
return this.riskService.assessDocument(id);
14+
}
15+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Module } from '@nestjs/common';
2+
import { DocumentsModule } from '../documents/documents.module';
3+
import { RiskAssessmentController } from './risk-assessment.controller';
4+
import { RiskAssessmentService } from './risk-assessment.service';
5+
6+
@Module({
7+
imports: [DocumentsModule],
8+
controllers: [RiskAssessmentController],
9+
providers: [RiskAssessmentService],
10+
exports: [RiskAssessmentService],
11+
})
12+
export class RiskAssessmentModule {}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Injectable, NotFoundException } from '@nestjs/common';
2+
3+
import { DocumentsService } from '../documents/documents.service';
4+
import { Document } from '../documents/entities/document.entity';
5+
6+
export enum RiskFlag {
7+
MISSING_PARCEL_ID = 'MISSING_PARCEL_ID',
8+
OVERLAPPING_CLAIM = 'OVERLAPPING_CLAIM',
9+
FORGED_SIGNATURE_INDICATOR = 'FORGED_SIGNATURE_INDICATOR',
10+
EXPIRED_DOCUMENT = 'EXPIRED_DOCUMENT',
11+
INCOMPLETE_OWNERSHIP_CHAIN = 'INCOMPLETE_OWNERSHIP_CHAIN',
12+
UNKNOWN_ISSUER = 'UNKNOWN_ISSUER',
13+
}
14+
15+
export interface RiskResult {
16+
score: number;
17+
flags: RiskFlag[];
18+
}
19+
20+
const FLAG_WEIGHTS: Record<RiskFlag, number> = {
21+
[RiskFlag.MISSING_PARCEL_ID]: 20,
22+
[RiskFlag.OVERLAPPING_CLAIM]: 20,
23+
[RiskFlag.FORGED_SIGNATURE_INDICATOR]: 25,
24+
[RiskFlag.EXPIRED_DOCUMENT]: 15,
25+
[RiskFlag.INCOMPLETE_OWNERSHIP_CHAIN]: 10,
26+
[RiskFlag.UNKNOWN_ISSUER]: 10,
27+
};
28+
29+
@Injectable()
30+
export class RiskAssessmentService {
31+
constructor(private readonly documentsService: DocumentsService) {}
32+
33+
async assessDocument(documentId: string): Promise<RiskResult> {
34+
const document = await this.documentsService.findById(documentId);
35+
if (!document) {
36+
throw new NotFoundException('Document not found');
37+
}
38+
39+
const flags = await this.detectFlags(document);
40+
const score = this.calculateScore(flags);
41+
42+
await this.documentsService.updateRisk(documentId, score, flags);
43+
44+
return { score, flags };
45+
}
46+
47+
private async detectFlags(document: Document): Promise<RiskFlag[]> {
48+
const flags: RiskFlag[] = [];
49+
50+
if (!document.title || !/\d/.test(document.title)) {
51+
flags.push(RiskFlag.MISSING_PARCEL_ID);
52+
}
53+
54+
const ownerDocuments = await this.documentsService.findByOwner(document.ownerId);
55+
if (ownerDocuments.some((doc) => doc.id !== document.id)) {
56+
flags.push(RiskFlag.OVERLAPPING_CLAIM);
57+
}
58+
59+
if (
60+
document.mimeType === 'application/pdf' &&
61+
document.fileSize !== undefined &&
62+
document.fileSize < 50_000
63+
) {
64+
flags.push(RiskFlag.FORGED_SIGNATURE_INDICATOR);
65+
}
66+
67+
if (document.title?.toLowerCase().includes('expired')) {
68+
flags.push(RiskFlag.EXPIRED_DOCUMENT);
69+
}
70+
71+
if (!document.title || document.title.trim().length < 12) {
72+
flags.push(RiskFlag.INCOMPLETE_OWNERSHIP_CHAIN);
73+
}
74+
75+
if (!document.title?.toLowerCase().includes('issued')) {
76+
flags.push(RiskFlag.UNKNOWN_ISSUER);
77+
}
78+
79+
return Array.from(new Set(flags));
80+
}
81+
82+
private calculateScore(flags: RiskFlag[]): number {
83+
const rawScore = flags.reduce((total, flag) => total + (FLAG_WEIGHTS[flag] ?? 0), 0);
84+
return Math.min(100, Math.max(0, rawScore));
85+
}
86+
}

0 commit comments

Comments
 (0)