Skip to content

Commit f683178

Browse files
authored
Merge pull request #245 from prismn/feat/setup-bullmq
Feat/setup bullmq
2 parents 8f7aba6 + aed6f1a commit f683178

14 files changed

Lines changed: 435 additions & 12 deletions

backend/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,14 @@ THROTTLE_LIMIT=10
4747
# File Upload
4848
UPLOAD_DIR=./uploads
4949

50+
# Stellar Configuration
51+
STELLAR_SECRET_KEY=your-stellar-secret-key
52+
STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
53+
STELLAR_NETWORK=Test SDF Network ; September 2015
54+
55+
# Redis Configuration
56+
REDIS_HOST=localhost
57+
REDIS_PORT=6379
58+
REDIS_PASSWORD=
59+
5060

backend/package-lock.json

Lines changed: 38 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"class-transformer": "^0.5.1",
4141
"class-validator": "^0.14.2",
4242
"exceljs": "^4.4.0",
43+
"ioredis": "^5.10.1",
4344
"nodemailer": "^7.0.12",
4445
"passport": "^0.7.0",
4546
"passport-github2": "^0.1.12",

backend/src/app.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { AppController } from './app.controller';
55
import { AppService } from './app.service';
66
import { AuthModule } from './auth/auth.module';
77
import { DocumentsModule } from './documents/documents.module';
8+
import { MailModule } from './mail/mail.module';
9+
import { QueueModule } from './queue/queue.module';
810
import { RiskAssessmentModule } from './risk-assessment/risk-assessment.module';
11+
import { StellarModule } from './stellar/stellar.module';
912
import { UsersModule } from './users/users.module';
1013
import { VerificationModule } from './verification/verification.module';
1114

@@ -33,7 +36,10 @@ import { VerificationModule } from './verification/verification.module';
3336
AuthModule,
3437
DocumentsModule,
3538
RiskAssessmentModule,
39+
StellarModule,
3640
VerificationModule,
41+
MailModule,
42+
QueueModule,
3743
],
3844
controllers: [AppController],
3945
providers: [AppService],

backend/src/documents/documents.controller.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import {
1+
import {
22
BadRequestException,
3+
ConflictException,
34
Controller,
5+
Get,
6+
NotFoundException,
7+
Param,
48
Post,
59
Req,
610
Res,
@@ -20,6 +24,8 @@ import { DocumentsService } from './documents.service';
2024
import { DocumentStatus } from './entities/document.entity';
2125
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
2226
import { User } from '../users/entities/user.entity';
27+
import { QueueService } from '../queue/queue.service';
28+
import { VerificationService } from '../verification/verification.service';
2329

2430
const ALLOWED_MIME_TYPES = ['application/pdf', 'image/png', 'image/jpeg'];
2531
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024;
@@ -39,6 +45,8 @@ export class DocumentsController {
3945
constructor(
4046
private readonly documentsService: DocumentsService,
4147
private readonly configService: ConfigService,
48+
private readonly queueService: QueueService,
49+
private readonly verificationService: VerificationService,
4250
) {}
4351

4452
@Post('upload')
@@ -74,7 +82,7 @@ export class DocumentsController {
7482
await fs.mkdir(uploadDir, { recursive: true });
7583

7684
const extension = extname(file.originalname) || '';
77-
const filename = ${fileHash};
85+
const filename = `${fileHash}${extension}`;
7886
const targetPath = join(uploadDir, filename);
7987
await fs.writeFile(targetPath, file.buffer);
8088

@@ -88,6 +96,43 @@ export class DocumentsController {
8896
status: DocumentStatus.PENDING,
8997
});
9098

91-
return res.status(201).send(document);
99+
await this.queueService.enqueueAnalyze(document.id);
100+
return res.status(202).send(document);
101+
}
102+
103+
@Post(':id/verify')
104+
@UseGuards(JwtAuthGuard)
105+
async verifyDocument(@Param('id') id: string, @Res() res: Response) {
106+
const document = await this.documentsService.findById(id);
107+
if (!document) {
108+
throw new NotFoundException('Document not found');
109+
}
110+
111+
if (document.status === DocumentStatus.VERIFIED) {
112+
throw new ConflictException('Document has already been verified');
113+
}
114+
115+
await this.queueService.enqueueAnchor(document.id);
116+
117+
return res.status(202).json({
118+
message: 'Verification queued',
119+
documentId: document.id,
120+
});
121+
}
122+
123+
@Get(':id/verification')
124+
@UseGuards(JwtAuthGuard)
125+
async getVerification(@Param('id') id: string) {
126+
const document = await this.documentsService.findById(id);
127+
if (!document) {
128+
throw new NotFoundException('Document not found');
129+
}
130+
131+
const record = await this.verificationService.findLatestByDocument(id);
132+
if (!record) {
133+
throw new NotFoundException('No verification record found for this document');
134+
}
135+
136+
return record;
92137
}
93138
}

backend/src/documents/documents.module.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1-
import { Module } from '@nestjs/common';
1+
import { forwardRef, Module } from '@nestjs/common';
22
import { ConfigModule } from '@nestjs/config';
33
import { TypeOrmModule } from '@nestjs/typeorm';
44
import { DocumentsController } from './documents.controller';
55
import { DocumentsService } from './documents.service';
66
import { Document } from './entities/document.entity';
7+
import { StellarModule } from '../stellar/stellar.module';
8+
import { VerificationModule } from '../verification/verification.module';
9+
import { QueueModule } from '../queue/queue.module';
710

811
@Module({
9-
imports: [ConfigModule, TypeOrmModule.forFeature([Document])],
12+
imports: [
13+
ConfigModule,
14+
TypeOrmModule.forFeature([Document]),
15+
StellarModule,
16+
VerificationModule,
17+
forwardRef(() => QueueModule),
18+
],
1019
controllers: [DocumentsController],
1120
providers: [DocumentsService],
1221
exports: [DocumentsService],

backend/src/mail/mail.module.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@nestjs/common';
2+
import { ConfigModule } from '@nestjs/config';
3+
import { MailService } from './mail.service';
4+
5+
@Module({
6+
imports: [ConfigModule],
7+
providers: [MailService],
8+
exports: [MailService],
9+
})
10+
export class MailModule {}

backend/src/mail/mail.service.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import nodemailer, { SendMailOptions, Transporter } from 'nodemailer';
4+
5+
@Injectable()
6+
export class MailService {
7+
private readonly logger = new Logger(MailService.name);
8+
private readonly transporter: Transporter | null;
9+
private readonly from?: string;
10+
11+
constructor(private readonly configService: ConfigService) {
12+
const host = this.configService.get<string>('MAIL_HOST');
13+
const port = Number(this.configService.get<string>('MAIL_PORT'));
14+
const user = this.configService.get<string>('MAIL_USER');
15+
const pass = this.configService.get<string>('MAIL_PASSWORD');
16+
this.from = this.configService.get<string>('MAIL_FROM');
17+
18+
if (!host || !port || !user || !pass || !this.from) {
19+
this.logger.warn('SMTP configuration is incomplete; email delivery will be disabled');
20+
this.transporter = null;
21+
return;
22+
}
23+
24+
this.transporter = nodemailer.createTransport({
25+
host,
26+
port,
27+
secure: port === 465,
28+
auth: { user, pass },
29+
});
30+
}
31+
32+
private async sendMail(options: SendMailOptions) {
33+
if (!this.transporter) {
34+
this.logger.warn(`Skipping email to ${options.to} because SMTP is not configured`);
35+
return;
36+
}
37+
38+
await this.transporter.sendMail({
39+
from: this.from,
40+
...options,
41+
});
42+
}
43+
44+
async sendWelcome(to: string, name: string): Promise<void> {
45+
await this.sendMail({
46+
to,
47+
subject: 'Welcome to Smalda',
48+
html: `<p>Hi ${name},</p><p>Thank you for joining Smalda. We are excited to help you secure your land documents.</p>`,
49+
});
50+
}
51+
52+
async sendVerificationComplete(to: string, documentTitle: string, txHash: string): Promise<void> {
53+
await this.sendMail({
54+
to,
55+
subject: 'Document Verification Complete',
56+
html: `
57+
<p>Your document <strong>${documentTitle}</strong> has been anchored on the Stellar network.</p>
58+
<p>Transaction hash: <code>${txHash}</code></p>
59+
<p>You can view the transaction via the Stellar Horizon explorer.</p>
60+
`,
61+
});
62+
}
63+
64+
async sendRiskAlert(to: string, documentTitle: string, flags: string[]): Promise<void> {
65+
const flagList = flags.map((flag) => `<li>${flag}</li>`).join('');
66+
await this.sendMail({
67+
to,
68+
subject: 'Risk Alert: Document Needs Attention',
69+
html: `
70+
<p>The document <strong>${documentTitle}</strong> triggered the following risk flags:</p>
71+
<ul>${flagList}</ul>
72+
<p>Please review the document and supply any missing information.</p>
73+
`,
74+
});
75+
}
76+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
2+
import { QueueScheduler, Worker } from 'bullmq';
3+
4+
import { DocumentsService } from '../documents/documents.service';
5+
import { DocumentStatus } from '../documents/entities/document.entity';
6+
import { VerificationService } from '../verification/verification.service';
7+
import { VerificationStatus } from '../verification/entities/verification-record.entity';
8+
import { RiskAssessmentService } from '../risk-assessment/risk-assessment.service';
9+
import { StellarService } from '../stellar/stellar.service';
10+
import { QueueService } from './queue.service';
11+
12+
@Injectable()
13+
export class DocumentProcessor implements OnModuleDestroy {
14+
private readonly logger = new Logger(DocumentProcessor.name);
15+
private readonly worker: Worker;
16+
private readonly scheduler: QueueScheduler;
17+
18+
constructor(
19+
private readonly queueService: QueueService,
20+
private readonly riskService: RiskAssessmentService,
21+
private readonly documentsService: DocumentsService,
22+
private readonly stellarService: StellarService,
23+
private readonly verificationService: VerificationService,
24+
) {
25+
const connection = this.queueService.getConnectionOptions();
26+
this.scheduler = new QueueScheduler(this.queueService.queueName, { connection });
27+
this.worker = new Worker(
28+
this.queueService.queueName,
29+
async (job) => {
30+
if (job.name === 'analyze') {
31+
await this.riskService.assessDocument(job.data.documentId);
32+
return;
33+
}
34+
if (job.name === 'anchor') {
35+
await this.handleAnchor(job.data.documentId);
36+
}
37+
},
38+
{ connection },
39+
);
40+
41+
this.worker.on('failed', (job, err) => {
42+
this.logger.error(`Job ${job.id} (${job.name}) failed`, err?.message, err?.stack);
43+
});
44+
}
45+
46+
private async handleAnchor(documentId: string) {
47+
const document = await this.documentsService.findById(documentId);
48+
if (!document) {
49+
this.logger.warn(`Document ${documentId} not found for anchor job`);
50+
return;
51+
}
52+
53+
const { txHash, ledger } = await this.stellarService.anchorHash(document.fileHash);
54+
await this.verificationService.create({
55+
documentId,
56+
stellarTxHash: txHash,
57+
stellarLedger: ledger,
58+
anchoredAt: new Date(),
59+
status: VerificationStatus.CONFIRMED,
60+
});
61+
62+
await this.documentsService.updateStatus(documentId, DocumentStatus.VERIFIED);
63+
this.logger.log(`Document ${documentId} verified on ledger ${ledger}`);
64+
}
65+
66+
async onModuleDestroy(): Promise<void> {
67+
await this.worker?.close();
68+
await this.scheduler?.close();
69+
}
70+
}

0 commit comments

Comments
 (0)