Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Increased the socket auto-disconnect timeout after event emission to ensure more reliable delivery and prevent premature disconnections during high-latency operations.
- Updated the `build:prod` script to execute `postbuild` after the build process, ensuring all necessary post-build steps are consistently applied in production builds.
- Method `findById` of PredicateService now returns the `email` and `notify` fields of predicate members.
- Refactored vault notification logic: moved email sending logic from PredicateController to NotificationService's `vaultCreate` method.

### Fixed

Expand Down
88 changes: 67 additions & 21 deletions packages/api/src/modules/notification/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import {
IFilterNotificationParams,
INotificationService,
IUpdateNotificationPayload,
IVaultCreateNotificationPayload,
} from './types';
import { DeepPartial } from 'typeorm';
import { TransactionService } from '../transaction/services';
import { UserService } from '../user/service';
import { EmailTemplateType, sendMail } from '@src/utils/EmailSender';
import { Network } from 'fuels';
import { SocketEvents, SocketUsernames } from '@src/socket/types';
import { PredicateService } from '../predicate/services';

import { logger } from '@src/config/logger';

const { API_URL } = process.env;
Expand Down Expand Up @@ -151,29 +153,73 @@ export class NotificationService implements INotificationService {
});
}

async vaultUpdate(vaultId: string) {
const vault = await new PredicateService().findById(vaultId);
async vaultCreate({
vaultId,
vaultName,
workspaceId,
network,
membersToNotify,
}: IVaultCreateNotificationPayload) {
try {
if (membersToNotify.length === 0) {
logger.info('[VAULT_CREATE] No members to notify, skipping notifications');
return;
}

if (!vault) {
return;
}
const membersNotificationInfos = await new UserService().getNotificationInfo(
membersToNotify,
);

const members = vault.members;
const notifyContent = {
vaultId,
vaultName,
workspaceId,
};

// Parallelize socket notifications for all members
await Promise.all(
members.map(member => {
const socketClient = new SocketClient(member.id, API_URL);
socketClient.emit(SocketEvents.NOTIFICATION, {
sessionId: member.id,
to: SocketUsernames.UI,
request_id: undefined,
type: SocketEvents.VAULT_UPDATE,
data: {},
});
return Promise.resolve();
}),
);
// Create notifications and send emails in parallel
await Promise.all(
membersNotificationInfos.map(async member => {
await this.create({
title: NotificationTitle.NEW_VAULT_CREATED,
user_id: member.id,
summary: notifyContent,
network,
});

if (member.notify && member.email) {
await sendMail(EmailTemplateType.VAULT_CREATED, {
to: member.email,
data: {
summary: { ...notifyContent, name: member?.name ?? '' },
},
});
}
}),
);

// Parallelize socket notifications for all members
await Promise.all(
membersNotificationInfos.map(member => {
const socketClient = new SocketClient(member.id, API_URL);
socketClient.emit(SocketEvents.NOTIFICATION, {
sessionId: member.id,
to: SocketUsernames.UI,
request_id: undefined,
type: SocketEvents.VAULT_UPDATE,
data: {},
});
return Promise.resolve();
}),
);
} catch (error) {
logger.error(
{
vaultId,
error: error?.message || error,
},
'[VAULT_CREATE] Error on vault create notifications',
);
}
}

async transactionUpdate(txId: string) {
Expand Down
9 changes: 9 additions & 0 deletions packages/api/src/modules/notification/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ interface IListNotificationsRequestSchema extends ValidatedRequestSchema {
};
}

export interface IVaultCreateNotificationPayload {
vaultId: string;
vaultName: string;
workspaceId: string;
network: Network;
membersToNotify: string[];
}

type IReadAllNotificationsRequestSchema = ValidatedRequestSchema;

export type IListNotificationsRequest = AuthValidatedRequest<IListNotificationsRequestSchema>;
Expand All @@ -58,4 +66,5 @@ export interface INotificationService {
networkUrl: string,
payload: IUpdateNotificationPayload,
) => Promise<boolean>;
vaultCreate: (payload: IVaultCreateNotificationPayload) => Promise<void>;
}
62 changes: 16 additions & 46 deletions packages/api/src/modules/predicate/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ import { logger } from '@src/config/logger';

import { Predicate } from '@src/models/Predicate';
import { Workspace } from '@src/models/Workspace';
import { EmailTemplateType, sendMail } from '@src/utils/EmailSender';

import { NotificationTitle } from '@models/index';

import { error, ErrorTypes, NotFound } from '@utils/error';
import {
Expand Down Expand Up @@ -36,8 +33,8 @@ import {
PredicateWithHidden,
} from './types';

import { NotificationService } from '../notification/services';
import { PredicateService } from './services';

const { FUEL_PROVIDER } = process.env;

export class PredicateController {
Expand Down Expand Up @@ -103,48 +100,21 @@ export class PredicateController {
'[PREDICATE_CREATE] Predicate created successfully',
);

const notifyDestination = predicate.members.filter(
member => user.id !== member.id,
);
const notifyContent = {
vaultId: predicate.id,
vaultName: predicate.name,
workspaceId: effectiveWorkspace.id,
};

await Promise.all(
notifyDestination.map(async member => {
try {
await this.notificationService.create({
title: NotificationTitle.NEW_VAULT_CREATED,
user_id: member.id,
summary: notifyContent,
network,
});

if (member.notify && member.email) {
await sendMail(EmailTemplateType.VAULT_CREATED, {
to: member.email,
data: {
summary: { ...notifyContent, name: member?.name ?? '' },
},
});
}
} catch (e) {
logger.error(
{
memberId: member?.id,
to: member.email,
predicateId: predicate.id,
error: e,
},
'[PREDICATE_CREATE] Failed to process member notification',
);
}
}),
);

await new NotificationService().vaultUpdate(predicate.id);
const membersToNotify = predicate.members
.filter(member => user.id !== member.id)
.map(member => member.id);

this.notificationService
.vaultCreate({
vaultId: predicate.id,
vaultName: predicate.name,
workspaceId: effectiveWorkspace.id,
network,
membersToNotify,
})
.catch(e =>
logger.error({ error: e }, '[PREDICATE_CREATE] Send notification failed'),
);

return successful(predicate, Responses.Created);
} catch (e) {
Expand Down
2 changes: 0 additions & 2 deletions packages/api/src/modules/predicate/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,6 @@ export class PredicateService implements IPredicateService {
'members.avatar',
'members.address',
'members.type',
'members.notify',
'members.email',
'owner.id',
'owner.address',
'owner.type',
Expand Down
28 changes: 28 additions & 0 deletions packages/api/src/modules/user/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
IUserPayload,
IValidateNameResponse,
IFindByNameResponse,
INotificationInfo,
} from './types';

import App from '@src/server/app';
Expand Down Expand Up @@ -441,4 +442,31 @@ export class UserService implements IUserService {
});
}
}

/**
* Retrieves notification preferences and email for specified users
* Used internally for sending notifications without exposing sensitive data
* @param ids - Array of user IDs to fetch notification info for
* @returns Promise<INotificationInfo[]> - User notification data (id, name, notify, email)
*/
async getNotificationInfo(ids: string[]): Promise<INotificationInfo[]> {
if (ids.length === 0) {
return [];
}

try {
const users = await User.createQueryBuilder('u')
.select(['u.id', 'u.name', 'u.notify', 'u.email'])
.where('u.id IN (:...ids)', { ids })
.getMany();

return users;
} catch (error) {
throw new Internal({
type: ErrorTypes.Internal,
title: 'Error on get notification info',
detail: error?.message || error,
});
}
}
}
8 changes: 8 additions & 0 deletions packages/api/src/modules/user/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export interface IValidateNameResponse {
type: TypeUser;
}

export interface INotificationInfo {
id: string;
name?: string;
notify: boolean;
email?: string;
}

interface ICreateRequestSchema extends ValidatedRequestSchema {
[ContainerTypes.Body]: IUserPayload;
}
Expand Down Expand Up @@ -164,4 +171,5 @@ export interface IUserService {
workspaceId: string;
network: Network;
}) => Promise<void>;
getNotificationInfo(ids: string[]): Promise<INotificationInfo[]>;
}
4 changes: 1 addition & 3 deletions packages/api/src/tests/transaction.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,7 @@ test('Transaction Endpoints', async t => {
const member = predicate.members.find(
member => member.id === element.owner.id,
);
for (const key of Object.keys(element.owner)) {
assert.deepStrictEqual(member[key], element.owner[key]);
}
assert.deepStrictEqual(element.owner, member);
});
},
);
Expand Down
Loading