Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3bfb60a
feat: alert via email while ledgerId gets set to null
GHkrishna Nov 25, 2025
c9beeb3
feat: alert via email while ledgerId gets set to null
GHkrishna Nov 25, 2025
3e6f9eb
fix: add pg query listen
GHkrishna Nov 26, 2025
cb3bbaa
fix: multiple event listner handle
GHkrishna Nov 27, 2025
41543fd
fix: log enabling of alerts
GHkrishna Nov 27, 2025
f1bba47
fix: add env demo and sample events
GHkrishna Nov 27, 2025
b0513b4
fix: remove unwanted imports
GHkrishna Nov 27, 2025
7f75778
fix: change toLocaleLowercase to toLowerCase
GHkrishna Nov 27, 2025
a86e787
fix: gracefully handle pg connect
GHkrishna Nov 27, 2025
1a56a14
fix: increase try catch scope
GHkrishna Nov 27, 2025
0c0fd05
fix: handle missing env variables gracefully
GHkrishna Nov 27, 2025
699dd5b
fix: handle positive scenario properly
GHkrishna Nov 27, 2025
ca1d597
fix: handle positive scenario properly
GHkrishna Nov 27, 2025
92317b9
fix: handle empty org_agents table in alerts
GHkrishna Nov 27, 2025
a1228b0
fix: handle retry logic and multiple notifications for send email
GHkrishna Nov 28, 2025
0e8dff2
fix: make pg obj readonly
GHkrishna Nov 28, 2025
fe698d0
finally reset flag
GHkrishna Nov 28, 2025
8d17f78
fix: Only initialize PgClient when DB_ALERT_ENABLE is true
GHkrishna Nov 28, 2025
08940e3
fix: toLowerLocaleCase to toLowerCase
GHkrishna Nov 28, 2025
d107960
fix: TODOs regarding email from platformconfig
GHkrishna Dec 1, 2025
8c82fc6
fix: cosmetic changes and missing env variable add in sample and demo
GHkrishna Dec 1, 2025
ab52155
fix: minor code rabbit changes
GHkrishna Dec 1, 2025
d34e792
fix: percentage threshold from common constants
GHkrishna Dec 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.demo
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,5 @@ RESEND_API_KEY=re_xxxxxxxxxx

# Prisma log type. Default set to error
PRISMA_LOGS = error
# DB_ALERT_ENABLE=
# DB_ALERT_EMAILS=
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,8 @@ RESEND_API_KEY=re_xxxxxxxxxx

# Prisma log type. Ideally should have only error or warn enabled. Having query enabled can add a lot of unwanted logging for all types of queries being run
# PRISMA_LOGS = error,warn,query

# Comma separated emails that needs to be alerted in case the 'ledgerId' is set to null
# DB_ALERT_EMAILS=
# Boolean: to enable/disable db alerts. This needs the 'utility' microservice
# DB_ALERT_ENABLE=
10 changes: 9 additions & 1 deletion apps/api-gateway/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ async function bootstrap(): Promise<void> {
);
app.useGlobalInterceptors(new NatsInterceptor());
await app.listen(process.env.API_GATEWAY_PORT, `${process.env.API_GATEWAY_HOST}`);
Logger.log(`API Gateway is listening on port ${process.env.API_GATEWAY_PORT}`);
Logger.log(`API Gateway is listening on port ${process.env.API_GATEWAY_PORT}`, 'Success');

if ('true' === process.env.DB_ALERT_ENABLE?.trim()?.toLocaleLowerCase()) {
// in case it is enabled, log that
Logger.log(
"We have enabled DB alert for 'ledger_null' instances. This would send email in case the 'ledger_id' column in 'org_agents' table is set to null",
'DB alert enabled'
);
}
}
bootstrap();
69 changes: 68 additions & 1 deletion apps/api-gateway/src/utilities/utilities.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,81 @@
import { StoreObjectDto, UtilitiesDto } from './dtos/shortening-url.dto';
import { NATSClient } from '@credebl/common/NATSClient';
import { ClientProxy } from '@nestjs/microservices';
import { Client as PgClient } from 'pg';

@Injectable()
export class UtilitiesService extends BaseService {
private pg: PgClient;

Check warning on line 10 in apps/api-gateway/src/utilities/utilities.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'pg' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=credebl_platform&issues=AZrFk1pKDvggpPC8XB3n&open=AZrFk1pKDvggpPC8XB3n&pullRequest=1526

constructor(
@Inject('NATS_CLIENT') private readonly serviceProxy: ClientProxy,
private readonly natsClient: NATSClient
) {
super('OrganizationService');
super('UtilitiesService');
this.pg = new PgClient({
connectionString: process.env.DATABASE_URL
});
}

async onModuleInit(): Promise<void> {
await this.pg.connect();

// Listen to the notification channel
await this.pg.query('LISTEN ledger_null');

// NATS is not available → skip silently
this.pg.on('notification', async (msg) => {
if ('true' !== process.env.DB_ALERT_ENABLE?.trim()?.toLocaleLowerCase()) {
// in case it is not enabled, return
return;
}

if ('ledger_null' === msg.channel) {
// Step 1: Count total records
const totalRes = await this.pg.query('SELECT COUNT(*) FROM org_agents');
const total = Number(totalRes.rows[0].count);

// Step 2: Count NULL ledgerId records
const nullRes = await this.pg.query('SELECT COUNT(*) FROM org_agents WHERE "ledgerId" IS NULL');
const nullCount = Number(nullRes.rows[0].count);

// Step 3: Calculate %
const percent = (nullCount / total) * 100;

// Condition: > 30%
if (30 >= percent) {
return;
}

const alertEmails =
process.env.DB_ALERT_EMAILS?.split(',')
.map((e) => e.trim())
.filter((e) => 0 < e.length) || [];

const emailDto = {
emailFrom: process.env.PUBLIC_PLATFORM_SUPPORT_EMAIL,
emailTo: alertEmails,
emailSubject: '[ALERT] More than 30% org_agents ledgerId is NULL',
emailText: `ALERT: ${percent.toFixed(2)}% of org_agents records currently have ledgerId = NULL.`,
emailHtml: `<p><strong>ALERT:</strong> ${percent.toFixed(
2
)}% of <code>org_agents</code> have <code>ledgerId</code> = NULL.</p>`
};

try {
const result = await this.natsClient.sendNatsMessage(this.serviceProxy, 'alert-db-ledgerId-null', {
emailDto
});
this.logger.debug('Received result', JSON.stringify(result, null, 2));
} catch (err) {
this.logger.error(err?.message ?? 'Some error occurred while sending prisma ledgerId alert email');
}
}
});
}

async onModuleDestroy(): Promise<void> {
await this.pg?.end();
}

async createShorteningUrl(shorteningUrlDto: UtilitiesDto): Promise<string> {
Expand Down
20 changes: 9 additions & 11 deletions apps/ledger/src/ledger.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Logger, Module } from '@nestjs/common';
import { LedgerController } from './ledger.controller';
import { LedgerService } from './ledger.service';
import { SchemaModule } from './schema/schema.module';
import { PrismaService } from '@credebl/prisma-service';
import { PrismaServiceModule } from '@credebl/prisma-service';
import { CredentialDefinitionModule } from './credential-definition/credential-definition.module';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { LedgerRepository } from './repositories/ledger.repository';
Expand All @@ -16,23 +16,21 @@ import { ContextInterceptorModule } from '@credebl/context/contextInterceptorMod
@Module({
imports: [
GlobalConfigModule,
LoggerModule, PlatformConfig, ContextInterceptorModule,
LoggerModule,
PlatformConfig,
ContextInterceptorModule,
ClientsModule.register([
{
name: 'NATS_CLIENT',
transport: Transport.NATS,
options: getNatsOptions(CommonConstants.LEDGER_SERVICE, process.env.LEDGER_NKEY_SEED)

}
]),
SchemaModule, CredentialDefinitionModule
SchemaModule,
CredentialDefinitionModule,
PrismaServiceModule
],
controllers: [LedgerController],
providers: [
LedgerService,
PrismaService,
LedgerRepository,
Logger
]
providers: [LedgerService, LedgerRepository, Logger]
})
export class LedgerModule { }
export class LedgerModule {}
14 changes: 14 additions & 0 deletions apps/utility/src/utilities.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Controller, Logger } from '@nestjs/common';
import { MessagePattern } from '@nestjs/microservices';
import { UtilitiesService } from './utilities.service';
import { IShorteningUrlData } from '../interfaces/shortening-url.interface';
import { EmailDto } from '@credebl/common/dtos/email.dto';

@Controller()
export class UtilitiesController {
Expand Down Expand Up @@ -30,4 +31,17 @@ export class UtilitiesController {
throw new Error('Error occured in Utility Microservices Controller');
}
}

@MessagePattern({ cmd: 'alert-db-ledgerId-null' })
async handleLedgerAlert(payload: { emailDto: EmailDto }): Promise<void> {
try {
this.logger.debug('Received msg in alert-db-service');
const result = await this.utilitiesService.handleLedgerAlert(payload.emailDto);
this.logger.debug('Received result in alert-db-service');
return result;
} catch (error) {
this.logger.error(error);
throw error;
}
}
}
118 changes: 71 additions & 47 deletions apps/utility/src/utilities.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,84 @@ import { UtilitiesRepository } from './utilities.repository';
import { AwsService } from '@credebl/aws';
import { S3 } from 'aws-sdk';
import { v4 as uuidv4 } from 'uuid';
import { EmailService } from '@credebl/common/email.service';
import { EmailDto } from '@credebl/common/dtos/email.dto';

@Injectable()
export class UtilitiesService {
constructor(
private readonly logger: Logger,
private readonly utilitiesRepository: UtilitiesRepository,
private readonly awsService: AwsService
) { }

async createAndStoreShorteningUrl(payload): Promise<string> {
try {
const { credentialId, schemaId, credDefId, invitationUrl, attributes } = payload;
const invitationPayload = {
referenceId: credentialId,
invitationPayload: {
schemaId,
credDefId,
invitationUrl,
attributes
}
};
await this.utilitiesRepository.saveShorteningUrl(invitationPayload);
return `${process.env.API_GATEWAY_PROTOCOL}://${process.env.API_ENDPOINT}/invitation/qr-code/${credentialId}`;
} catch (error) {
this.logger.error(`[createAndStoreShorteningUrl] - error in create shortening url: ${JSON.stringify(error)}`);
throw new RpcException(error);
private lastAlertTime: number | null = null;

constructor(
private readonly logger: Logger,
private readonly utilitiesRepository: UtilitiesRepository,
private readonly awsService: AwsService,
private readonly emailService: EmailService
) {}

async createAndStoreShorteningUrl(payload): Promise<string> {
try {
const { credentialId, schemaId, credDefId, invitationUrl, attributes } = payload;
const invitationPayload = {
referenceId: credentialId,
invitationPayload: {
schemaId,
credDefId,
invitationUrl,
attributes
}
};
await this.utilitiesRepository.saveShorteningUrl(invitationPayload);
return `${process.env.API_GATEWAY_PROTOCOL}://${process.env.API_ENDPOINT}/invitation/qr-code/${credentialId}`;
} catch (error) {
this.logger.error(`[createAndStoreShorteningUrl] - error in create shortening url: ${JSON.stringify(error)}`);
throw new RpcException(error);
}
}

async getShorteningUrl(referenceId: string): Promise<object> {
try {
const getShorteningUrl = await this.utilitiesRepository.getShorteningUrl(referenceId);

const getInvitationUrl = {
referenceId: getShorteningUrl.referenceId,
invitationPayload: getShorteningUrl.invitationPayload
};

return getInvitationUrl;
} catch (error) {
this.logger.error(`[getShorteningUrl] - error in get shortening url: ${JSON.stringify(error)}`);
throw new RpcException(error);
}
async getShorteningUrl(referenceId: string): Promise<object> {
try {
const getShorteningUrl = await this.utilitiesRepository.getShorteningUrl(referenceId);

const getInvitationUrl = {
referenceId: getShorteningUrl.referenceId,
invitationPayload: getShorteningUrl.invitationPayload
};

return getInvitationUrl;
} catch (error) {
this.logger.error(`[getShorteningUrl] - error in get shortening url: ${JSON.stringify(error)}`);
throw new RpcException(error);
}
}

async storeObject(payload: {persistent: boolean, storeObj: unknown}): Promise<string> {
try {
const uuid = uuidv4();
const uploadResult:S3.ManagedUpload.SendData = await this.awsService.storeObject(payload.persistent, uuid, payload.storeObj);
const url: string = `${process.env.SHORTENED_URL_DOMAIN}/${uploadResult.Key}`;
return url;
} catch (error) {
this.logger.error(error);
throw new Error('An error occurred while uploading data to S3. Error::::::');
}
async storeObject(payload: { persistent: boolean; storeObj: unknown }): Promise<string> {
try {
const uuid = uuidv4();
const uploadResult: S3.ManagedUpload.SendData = await this.awsService.storeObject(
payload.persistent,
uuid,
payload.storeObj
);
const url: string = `${process.env.SHORTENED_URL_DOMAIN}/${uploadResult.Key}`;
return url;
} catch (error) {
this.logger.error(error);
throw new Error('An error occurred while uploading data to S3. Error::::::');
}
}

async handleLedgerAlert(emailDto: EmailDto): Promise<void> {
// Avoid spamming: send only once every 2 hours
const now = Date.now();
if (this.lastAlertTime && now - this.lastAlertTime < 2 * 60 * 60 * 1000) {
this.logger.log(`ALERT EMAIL ALREADY SENT at ${this.lastAlertTime}, ledgerId WAS SET TO NULL`);
return;
}
this.lastAlertTime = now;

// Send Email
await this.emailService.sendEmail(emailDto);

this.logger.log('ALERT EMAIL SENT, ledgerId WAS SET TO NULL');
}
}
26 changes: 13 additions & 13 deletions libs/common/src/dtos/email.dto.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
export class EmailDto {
emailFrom: string;
emailTo: string;
emailSubject: string;
emailText: string;
emailHtml: string;
emailAttachments?: AttachmentJSON[];
emailFrom: string;
emailTo: string | string[];
emailSubject: string;
emailText: string;
emailHtml: string;
emailAttachments?: AttachmentJSON[];
}

interface AttachmentJSON {
content: string;
filename: string;
contentType: string;
type?: string;
disposition?: string;
content_id?: string;
}
content: string;
filename: string;
contentType: string;
type?: string;
disposition?: string;
content_id?: string;
}
2 changes: 1 addition & 1 deletion libs/common/src/resend-helper-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
throw new Error('Missing RESEND_API_KEY in environment variables.');
}
const resend = new Resend(process.env.RESEND_API_KEY);
const resend = new Resend(apiKey);

export const sendWithResend = async (emailDto: EmailDto): Promise<boolean> => {
try {
Expand Down
5 changes: 3 additions & 2 deletions libs/org-roles/src/org-roles.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { PrismaService } from '@credebl/prisma-service';
import { PrismaServiceModule } from '@credebl/prisma-service';
import { Logger } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { OrgRolesRepository } from '../repositories';
import { OrgRolesService } from './org-roles.service';

@Module({
providers: [OrgRolesService, OrgRolesRepository, Logger, PrismaService],
imports: [PrismaServiceModule],
providers: [OrgRolesService, OrgRolesRepository, Logger],
exports: [OrgRolesService]
})
export class OrgRolesModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Create the function
CREATE OR REPLACE FUNCTION alert_ledger_null()
RETURNS trigger AS $$
BEGIN
IF NEW."ledgerId" IS NULL THEN
PERFORM pg_notify('ledger_null', json_build_object(
'agentId', NEW.id,
'orgId', NEW."orgId",
'timestamp', now()
)::text);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Create the trigger
CREATE TRIGGER ledger_null_trigger
AFTER UPDATE ON org_agents
FOR EACH ROW
WHEN (NEW."ledgerId" IS NULL AND OLD."ledgerId" IS NOT NULL)
EXECUTE FUNCTION alert_ledger_null();
Loading