diff --git a/libraries/grpc-sdk/src/index.ts b/libraries/grpc-sdk/src/index.ts index 93c906823..a3c3b64fe 100644 --- a/libraries/grpc-sdk/src/index.ts +++ b/libraries/grpc-sdk/src/index.ts @@ -3,13 +3,11 @@ import { Authentication, Authorization, Chat, + Comms, Config, Core, DatabaseProvider, - Email, - PushNotifications, Router, - SMS, Storage, } from './modules/index.js'; import crypto from 'crypto'; @@ -51,11 +49,9 @@ class ConduitGrpcSdk { router: Router, database: DatabaseProvider, storage: Storage, - email: Email, - pushNotifications: PushNotifications, + comms: Comms, authentication: Authentication, authorization: Authorization, - sms: SMS, chat: Chat, }; private _dynamicModules: { [key: string]: CompatServiceDefinition } = {}; @@ -213,20 +209,11 @@ class ConduitGrpcSdk { } } - get emailProvider(): Email | null { - if (this._modules['email']) { - return this._modules['email'] as Email; + get comms(): Comms | null { + if (this._modules['comms']) { + return this._modules['comms'] as Comms; } else { - ConduitGrpcSdk.Logger.warn('Email provider not up yet!'); - return null; - } - } - - get pushNotifications(): PushNotifications | null { - if (this._modules['pushNotifications']) { - return this._modules['pushNotifications'] as PushNotifications; - } else { - ConduitGrpcSdk.Logger.warn('Push notifications module not up yet!'); + ConduitGrpcSdk.Logger.warn('Comms not up yet!'); return null; } } @@ -249,15 +236,6 @@ class ConduitGrpcSdk { } } - get sms(): SMS | null { - if (this._modules['sms']) { - return this._modules['sms'] as SMS; - } else { - ConduitGrpcSdk.Logger.warn('SMS module not up yet!'); - return null; - } - } - get chat(): Chat | null { if (this._modules['chat']) { return this._modules['chat'] as Chat; diff --git a/libraries/grpc-sdk/src/modules/email/index.ts b/libraries/grpc-sdk/src/modules/comms/email/index.ts similarity index 92% rename from libraries/grpc-sdk/src/modules/email/index.ts rename to libraries/grpc-sdk/src/modules/comms/email/index.ts index 61e52cf13..1f930490f 100644 --- a/libraries/grpc-sdk/src/modules/email/index.ts +++ b/libraries/grpc-sdk/src/modules/comms/email/index.ts @@ -1,5 +1,5 @@ -import { ConduitModule } from '../../classes/index.js'; -import { EmailDefinition } from '../../protoUtils/email.js'; +import { ConduitModule } from '../../../classes/index.js'; +import { EmailDefinition } from '../../../protoUtils/index.js'; export class Email extends ConduitModule { constructor( diff --git a/libraries/grpc-sdk/src/modules/comms/index.ts b/libraries/grpc-sdk/src/modules/comms/index.ts new file mode 100644 index 000000000..9b1ae38cb --- /dev/null +++ b/libraries/grpc-sdk/src/modules/comms/index.ts @@ -0,0 +1,39 @@ +import { ConduitModule } from '../../classes/index.js'; +import { CommsDefinition } from '../../protoUtils/index.js'; +import { Email } from './email'; +import { SMS } from './sms'; +import { PushNotifications } from './pushNotifications'; + +export class Comms extends ConduitModule { + private readonly _email: Email; + private readonly _sms: SMS; + private readonly _pushNotifications: PushNotifications; + + constructor( + private readonly moduleName: string, + url: string, + grpcToken?: string, + ) { + super(moduleName, 'comms', url, grpcToken); + this.initializeClient(CommsDefinition); + this._email = new Email(moduleName, url, grpcToken); + this._sms = new SMS(moduleName, url, grpcToken); + this._pushNotifications = new PushNotifications(moduleName, url, grpcToken); + } + + get email() { + return this._email; + } + + get sms() { + return this._sms; + } + + get pushNotifications() { + return this._pushNotifications; + } + + featureAvailable(name: string) { + return this.client!.featureAvailable({ serviceName: name }); + } +} diff --git a/libraries/grpc-sdk/src/modules/pushNotifications/index.ts b/libraries/grpc-sdk/src/modules/comms/pushNotifications/index.ts similarity index 96% rename from libraries/grpc-sdk/src/modules/pushNotifications/index.ts rename to libraries/grpc-sdk/src/modules/comms/pushNotifications/index.ts index 72f5bd769..c5bddcc07 100644 --- a/libraries/grpc-sdk/src/modules/pushNotifications/index.ts +++ b/libraries/grpc-sdk/src/modules/comms/pushNotifications/index.ts @@ -1,8 +1,8 @@ -import { ConduitModule } from '../../classes/index.js'; +import { ConduitModule } from '../../../classes/index.js'; import { PushNotificationsDefinition, SendNotificationResponse, -} from '../../protoUtils/index.js'; +} from '../../../protoUtils/index.js'; import { SendNotificationOptions } from './types'; import { isNil } from 'lodash'; diff --git a/libraries/grpc-sdk/src/modules/pushNotifications/types.ts b/libraries/grpc-sdk/src/modules/comms/pushNotifications/types.ts similarity index 100% rename from libraries/grpc-sdk/src/modules/pushNotifications/types.ts rename to libraries/grpc-sdk/src/modules/comms/pushNotifications/types.ts diff --git a/libraries/grpc-sdk/src/modules/sms/index.ts b/libraries/grpc-sdk/src/modules/comms/sms/index.ts similarity index 88% rename from libraries/grpc-sdk/src/modules/sms/index.ts rename to libraries/grpc-sdk/src/modules/comms/sms/index.ts index 31942e7b8..34fec34ca 100644 --- a/libraries/grpc-sdk/src/modules/sms/index.ts +++ b/libraries/grpc-sdk/src/modules/comms/sms/index.ts @@ -1,10 +1,10 @@ -import { ConduitModule } from '../../classes/index.js'; +import { ConduitModule } from '../../../classes/index.js'; import { SendSmsResponse, SendVerificationCodeResponse, SmsDefinition, VerifyResponse, -} from '../../protoUtils/sms.js'; +} from '../../../protoUtils/index.js'; export class SMS extends ConduitModule { constructor( diff --git a/libraries/grpc-sdk/src/modules/index.ts b/libraries/grpc-sdk/src/modules/index.ts index 16831e4d2..577b982c2 100644 --- a/libraries/grpc-sdk/src/modules/index.ts +++ b/libraries/grpc-sdk/src/modules/index.ts @@ -1,12 +1,13 @@ export * from './storage/index.js'; export * from './router/index.js'; -export * from './email/index.js'; export * from './database/index.js'; export * from './config/index.js'; +export * from './comms/index.js'; +export * from './comms/email/index.js'; +export * from './comms/sms/index.js'; +export * from './comms/pushNotifications/index.js'; export * from './core/index.js'; export * from './admin/index.js'; -export * from './pushNotifications/index.js'; -export * from './sms/index.js'; export * from './chat/index.js'; export * from './authorization/index.js'; export * from './authentication/index.js'; diff --git a/libraries/module-tools/src/ManagedModule.ts b/libraries/module-tools/src/ManagedModule.ts index 3536d1a27..fb472df32 100644 --- a/libraries/module-tools/src/ManagedModule.ts +++ b/libraries/module-tools/src/ManagedModule.ts @@ -158,14 +158,27 @@ export abstract class ManagedModule extends ConduitServiceModule { async startGrpcServer() { if (this.service) { - this._serviceName = this.service.protoDescription.substring( - this.service.protoDescription.indexOf('.') + 1, - ); - await this.grpcServer.addService( - this.service.protoPath, - this.service.protoDescription, - this.service.functions, - ); + // singular service + if (this.service.protoDescription.includes('.')) { + this._serviceName = this.service.protoDescription.substring( + this.service.protoDescription.indexOf('.') + 1, + ); + await this.grpcServer.addService( + this.service.protoPath, + this.service.protoDescription, + this.service.functions as { [name: string]: Function }, + ); + } else { + const packageName = this.service.protoDescription; + for (const service of Object.keys(this.service.functions)) { + this._serviceName = packageName + '.' + service; + await this.grpcServer.addService( + this.service.protoPath, + this._serviceName, + this.service.functions[service] as { [name: string]: Function }, + ); + } + } } RoutingManager.ClientController = new RoutingController(); RoutingManager.AdminController = new RoutingController(); diff --git a/libraries/module-tools/src/interfaces/ConduitService.ts b/libraries/module-tools/src/interfaces/ConduitService.ts index e93b2079b..5494e8e93 100644 --- a/libraries/module-tools/src/interfaces/ConduitService.ts +++ b/libraries/module-tools/src/interfaces/ConduitService.ts @@ -1,12 +1,14 @@ import { GrpcRequest, GrpcResponse } from '@conduitplatform/grpc-sdk'; +export type ServiceFunction = ( + call: GrpcRequest, + callback: GrpcResponse, +) => void | Promise; + export interface ConduitService { readonly protoPath: string; readonly protoDescription: string; functions: { - [p: string]: ( - call: GrpcRequest, - callback: GrpcResponse, - ) => void | Promise; + [p: string]: ServiceFunction | { [p: string]: ServiceFunction }; }; } diff --git a/libraries/testing-tools/src/mock-module/index.ts b/libraries/testing-tools/src/mock-module/index.ts index 6f7c5b4f4..736a47e34 100644 --- a/libraries/testing-tools/src/mock-module/index.ts +++ b/libraries/testing-tools/src/mock-module/index.ts @@ -3,14 +3,14 @@ import { Channel, Client, createChannel, createClientFactory } from 'nice-grpc'; import { getModuleNameInterceptor } from './utils'; import { HealthCheckResponse, HealthDefinition } from '../protoUtils/grpc_health_check'; import { EventEmitter } from 'events'; -import { EmailDefinition } from '../protoUtils/email'; +import { EmailDefinition } from '../protoUtils/comms'; import { RouterDefinition } from '../protoUtils/router'; import { DatabaseProviderDefinition } from '../protoUtils/database'; import { StorageDefinition } from '../protoUtils/storage'; -import { PushNotificationsDefinition } from '../protoUtils/push-notifications'; +import { PushNotificationsDefinition } from '../protoUtils/comms'; import { AuthenticationDefinition } from '../protoUtils/authentication'; import { AuthorizationDefinition } from '../protoUtils/authorization'; -import { SmsDefinition } from '../protoUtils/sms'; +import { SmsDefinition } from '../protoUtils/comms'; import { ChatDefinition } from '../protoUtils/chat'; export default class MockModule { diff --git a/modules/authentication/src/Authentication.ts b/modules/authentication/src/Authentication.ts index e1c11bddc..7fd850c4f 100644 --- a/modules/authentication/src/Authentication.ts +++ b/modules/authentication/src/Authentication.ts @@ -12,6 +12,7 @@ import AppConfigSchema, { Config } from './config/index.js'; import { AdminHandlers } from './admin/index.js'; import { AuthenticationRoutes } from './routes/index.js'; import * as models from './models/index.js'; +import { User } from './models/index.js'; import { AuthUtils } from './utils/index.js'; import { TokenType } from './constants/index.js'; import { v4 as uuid } from 'uuid'; @@ -50,7 +51,6 @@ import { User as UserAuthz } from './authz/index.js'; import { handleAuthentication } from './routes/middleware.js'; import { fileURLToPath } from 'node:url'; import { TeamsHandler } from './handlers/team.js'; -import { User } from './models/index.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -313,7 +313,9 @@ export default class Authentication extends ManagedModule { } const sendEmail = ConfigController.getInstance().config.local.verification.send_email; - const emailAvailable = this.grpcSdk.isAvailable('email'); + const emailAvailable = + this.grpcSdk.isAvailable('comms') && + (await this.grpcSdk.comms?.featureAvailable('email')); if (verify && sendEmail && emailAvailable) { const serverConfig = await this.grpcSdk.config.get('router'); const url = serverConfig.hostUrl; @@ -324,7 +326,7 @@ export default class Authentication extends ManagedModule { }); const result = { verificationToken, hostUrl: url }; const link = `${result.hostUrl}/hook/authentication/verify-email/${result.verificationToken.token}`; - await this.grpcSdk.emailProvider!.sendEmail('EmailVerification', { + await this.grpcSdk.comms?.email!.sendEmail('EmailVerification', { email: user.email, variables: { link, diff --git a/modules/authentication/src/handlers/local.ts b/modules/authentication/src/handlers/local.ts index 0b1fd4804..b31122233 100644 --- a/modules/authentication/src/handlers/local.ts +++ b/modules/authentication/src/handlers/local.ts @@ -709,11 +709,12 @@ export class LocalHandlers implements IAuthenticationStrategy { private async initDbAndEmail() { const config = ConfigController.getInstance().config; - if (config.local.verification.send_email && this.grpcSdk.isAvailable('email')) { - this.emailModule = this.grpcSdk.emailProvider!; - } - - if (config.local.verification.send_email && this.grpcSdk.isAvailable('email')) { + if ( + config.local.verification.send_email && + this.grpcSdk.isAvailable('comms') && + this.grpcSdk.comms?.featureAvailable('email') + ) { + this.emailModule = this.grpcSdk.comms.email!; this.registerTemplates(); } this.initialized = true; diff --git a/modules/authentication/src/handlers/magicLink.ts b/modules/authentication/src/handlers/magicLink.ts index 0708cbadd..a33c68a79 100644 --- a/modules/authentication/src/handlers/magicLink.ts +++ b/modules/authentication/src/handlers/magicLink.ts @@ -30,8 +30,12 @@ export class MagicLinkHandlers implements IAuthenticationStrategy { async validate(): Promise { const config = ConfigController.getInstance().config; - if (config.magic_link.enabled && this.grpcSdk.isAvailable('email')) { - this.emailModule = this.grpcSdk.emailProvider!; + if ( + config.magic_link.enabled && + this.grpcSdk.isAvailable('comms') && + (await this.grpcSdk.comms!.featureAvailable('email')) + ) { + this.emailModule = this.grpcSdk.comms?.email!; const success = await this.registerTemplate() .then(() => true) .catch(e => { diff --git a/modules/authentication/src/handlers/phone.ts b/modules/authentication/src/handlers/phone.ts index f1bd92a79..8de97f0e3 100644 --- a/modules/authentication/src/handlers/phone.ts +++ b/modules/authentication/src/handlers/phone.ts @@ -31,9 +31,11 @@ export class PhoneHandlers implements IAuthenticationStrategy { async validate(): Promise { const config = ConfigController.getInstance().config; - const isAvailable = this.grpcSdk.isAvailable('sms'); + const isAvailable = + this.grpcSdk.isAvailable('comms') && + (await this.grpcSdk.comms!.featureAvailable('sms')); if (config.phoneAuthentication.enabled && isAvailable) { - this.sms = this.grpcSdk.sms!; + this.sms = this.grpcSdk.comms!.sms!; ConduitGrpcSdk.Logger.log('Phone authentication is available'); return (this.initialized = true); } else { diff --git a/modules/authentication/src/handlers/team.ts b/modules/authentication/src/handlers/team.ts index c7b2bd2e3..dd58d9494 100644 --- a/modules/authentication/src/handlers/team.ts +++ b/modules/authentication/src/handlers/team.ts @@ -539,14 +539,19 @@ export class TeamsHandler implements IAuthenticationStrategy { role, inviter: user, }); - if (email && config.teams.invites.sendEmail && this.grpcSdk.isAvailable('email')) { + if ( + email && + config.teams.invites.sendEmail && + this.grpcSdk.isAvailable('comms') && + (await this.grpcSdk.comms?.featureAvailable('email')) + ) { let link = !isEmpty(redirectUri) ? AuthUtils.validateRedirectUri(redirectUri) : config.teams.invites.inviteUrl; link += `?invitationToken=${invitation.token}`; await this.grpcSdk - .emailProvider!.sendEmail('TeamInvite', { + .comms!.email!.sendEmail('TeamInvite', { email: email, variables: { link, @@ -554,7 +559,7 @@ export class TeamsHandler implements IAuthenticationStrategy { inviterName: user.name, }, }) - .catch(e => { + .catch((e: Error) => { ConduitGrpcSdk.Logger.error(e); }); } @@ -854,14 +859,19 @@ export class TeamsHandler implements IAuthenticationStrategy { } } if (config.teams.invites.enabled && config.teams.invites.sendEmail) { - if (!config.teams.invites.sendEmail || !this.grpcSdk.isAvailable('email')) { + if ( + !config.teams.invites.sendEmail || + !this.grpcSdk.isAvailable('comms') || + !(await this.grpcSdk.comms!.featureAvailable('email')) + ) { ConduitGrpcSdk.Logger.warn( 'Team invites are enabled, but email sending is disabled. No invites will be sent.', ); } if (config.teams.invites.sendEmail) { - this.grpcSdk.onceModuleUp('email', async () => { - await this.grpcSdk.emailProvider!.registerTemplate(TeamInviteTemplate); + this.grpcSdk.onceModuleUp('comms', async () => { + // email doesn't have to be generally serving to user registerTemplate + await this.grpcSdk.comms!.email.registerTemplate(TeamInviteTemplate); }); } } diff --git a/modules/authentication/src/handlers/twoFa.ts b/modules/authentication/src/handlers/twoFa.ts index 01b739fbb..e2b6f4b6f 100644 --- a/modules/authentication/src/handlers/twoFa.ts +++ b/modules/authentication/src/handlers/twoFa.ts @@ -37,11 +37,15 @@ export class TwoFa implements IAuthenticationStrategy { return false; } if (authConfig.twoFa.enabled && authConfig.twoFa.methods.sms) { - if (!this.grpcSdk.isAvailable('sms')) { + if ( + !this.grpcSdk.isAvailable('comms') || + !(await this.grpcSdk.comms!.featureAvailable('sms')) + ) { ConduitGrpcSdk.Logger.error('SMS module not available'); return false; } } + this.smsModule = this.grpcSdk.comms!.sms!; ConduitGrpcSdk.Logger.log('TwoFactor authentication is available'); return true; } diff --git a/modules/authentication/src/utils/index.ts b/modules/authentication/src/utils/index.ts index 0a68df0ef..7162b24ee 100644 --- a/modules/authentication/src/utils/index.ts +++ b/modules/authentication/src/utils/index.ts @@ -92,7 +92,7 @@ export namespace AuthUtils { token: Token, code: string, ): Promise { - const verified = await grpcSdk.sms!.verify(token.data.verification, code); + const verified = await grpcSdk.comms!.sms!.verify(token.data.verification, code); if (!verified.verified) { return false; } diff --git a/modules/chat/src/Chat.ts b/modules/chat/src/Chat.ts index a93c52427..1f71310d9 100644 --- a/modules/chat/src/Chat.ts +++ b/modules/chat/src/Chat.ts @@ -47,8 +47,8 @@ export default class Chat extends ManagedModule { private adminRouter: AdminHandlers; private userRouter: ChatRoutes; private database: DatabaseProvider; - private _emailServing: boolean; - private _pushNotificationsServing: boolean; + private _emailServing: boolean = false; + private _pushNotificationsServing: boolean = false; constructor() { super('chat'); @@ -133,7 +133,7 @@ export default class Chat extends ManagedModule { } try { - await validateUsersInput(this.grpcSdk, participants); + await validateUsersInput(participants); } catch (e) { return callback({ code: (e as GrpcError).code, message: (e as GrpcError).message }); } diff --git a/modules/chat/src/admin/index.ts b/modules/chat/src/admin/index.ts index bcfeb4d73..3b0ebd211 100644 --- a/modules/chat/src/admin/index.ts +++ b/modules/chat/src/admin/index.ts @@ -9,17 +9,27 @@ import { UnparsedRouterResponse, } from '@conduitplatform/grpc-sdk'; import { + ConduitBoolean, ConduitNumber, + ConduitObjectId, ConduitString, + ConfigController, GrpcServer, RoutingManager, } from '@conduitplatform/module-tools'; import { status } from '@grpc/grpc-js'; -import { isNil } from 'lodash-es'; -import { populateArray } from '../utils/index.js'; -import { ChatMessage, ChatRoom, User } from '../models/index.js'; +import { isEmpty, isNil } from 'lodash-es'; +import { + ChatMessage, + ChatParticipantsLog, + ChatRoom, + InvitationToken, + User, +} from '../models/index.js'; +import { Config } from '../config/index.js'; import escapeStringRegexp from 'escape-string-regexp'; +import { sendInvitations } from '../utils/index.js'; export class AdminHandlers { private readonly routingManager: RoutingManager; @@ -33,18 +43,30 @@ export class AdminHandlers { } async getRooms(call: ParsedRouterRequest): Promise { - const { sort, search, populate } = call.request.params; + const { sort, search, users, deleted, populate } = call.request.params as { + sort?: string[]; + search?: string; + users?: string[]; + deleted?: boolean; + populate?: string[]; + }; const { skip } = call.request.params ?? 0; const { limit } = call.request.params ?? 25; - let query: Query = {}; - let identifier, populates; - if (!isNil(populate)) { - populates = populateArray(populate); - } + let query: Query = { + $and: [], + }; if (!isNil(search)) { - identifier = escapeStringRegexp(search); - query = { name: { $regex: `.*${identifier}.*`, $options: 'i' } }; + query.$and?.push({ + name: { $regex: `.*${escapeStringRegexp(search)}.*`, $options: 'i' }, + }); + } + if (!isNil(deleted)) { + query.$and?.push({ deleted }); + } + if (!isEmpty(users)) { + query.$and?.push({ participants: { $in: users } }); } + if (!query.$and?.length) query = {}; const chatRoomDocumentsPromise = ChatRoom.getInstance().findMany( query, @@ -52,7 +74,7 @@ export class AdminHandlers { skip, limit, sort, - populates, + populate, ); const totalCountPromise = ChatRoom.getInstance().countDocuments(query); @@ -67,7 +89,10 @@ export class AdminHandlers { } async createRoom(call: ParsedRouterRequest): Promise { - const { participants } = call.request.params as { participants: string[] }; + const { participants, creator } = call.request.params as { + participants: string[]; + creator: string; + }; if (participants.length === 0) { // array check is required throw new GrpcError( @@ -76,20 +101,37 @@ export class AdminHandlers { ); } await this.validateUsersInput(participants); - const chatRoom = await ChatRoom.getInstance() + const unique = new Set([...participants]); + let chatRoom = await ChatRoom.getInstance() .create({ name: call.request.params.name, - participants: Array.from(new Set([...participants])), + participants: Array.from(unique), + creator: creator, }) .catch((e: Error) => { throw new GrpcError(status.INTERNAL, e.message); }); + const participantsLog = await ChatParticipantsLog.getInstance().createMany([ + { + user: creator, + action: 'create', + chatRoom: chatRoom._id, + }, + ...Array.from(unique).map((userId: string) => ({ + user: userId, + action: 'join', + chatRoom: chatRoom._id, + })), + ]); + chatRoom = (await ChatRoom.getInstance().findByIdAndUpdate(chatRoom._id, { + participantsLog: participantsLog.map(log => log._id), + })) as ChatRoom; ConduitGrpcSdk.Metrics?.increment('chat_rooms_total'); return chatRoom; } async deleteRooms(call: ParsedRouterRequest): Promise { - const { ids } = call.request.params; + const { ids } = call.request.params as { ids: string[] }; if (ids.length === 0) { // array check is required throw new GrpcError( @@ -97,32 +139,33 @@ export class AdminHandlers { 'ids is required and must be a non-empty array', ); } - await ChatRoom.getInstance() - .deleteMany({ _id: { $in: ids } }) - .catch((e: Error) => { - throw new GrpcError(status.INTERNAL, e.message); - }); - await ChatMessage.getInstance() - .deleteMany({ room: { $in: ids } }) - .catch((e: Error) => { - throw new GrpcError(status.INTERNAL, e.message); - }); + const config = ConfigController.getInstance().config as Config; + if (config.auditMode) { + await Promise.all( + ids.map(roomId => { + ChatRoom.getInstance().findByIdAndUpdate(roomId, { + deleted: true, + }); + ChatMessage.getInstance().updateMany({ room: roomId }, { deleted: true }); + }), + ); + } else { + await ChatRoom.getInstance().deleteMany({ _id: { $in: ids } }); + await ChatMessage.getInstance().deleteMany({ room: { $in: ids } }); + await ChatParticipantsLog.getInstance().deleteMany({ chatRoom: { $in: ids } }); + } ConduitGrpcSdk.Metrics?.decrement('chat_rooms_total'); return 'Done'; } async getMessages(call: ParsedRouterRequest): Promise { - const { senderUser, roomId, populate, sort } = call.request.params; + const { senderUser, roomId, populate, sort, search } = call.request.params; const { skip } = call.request.params ?? 0; const { limit } = call.request.params ?? 25; const query: Query = { ...(senderUser ? { senderUser } : {}), ...(roomId ? { room: roomId } : {}), }; - let populates; - if (!isNil(populate)) { - populates = populateArray(populate); - } if (!isNil(senderUser)) { const user = await User.getInstance().findOne({ _id: senderUser }); if (isNil(user)) { @@ -135,6 +178,9 @@ export class AdminHandlers { throw new GrpcError(status.NOT_FOUND, `Room ${roomId} does not exists`); } } + if (!isNil(search)) { + query.message = { $regex: search, $options: 'i' }; + } const messagesPromise = ChatMessage.getInstance().findMany( query, @@ -142,7 +188,7 @@ export class AdminHandlers { skip, limit, sort, - populates, + populate, ); const countPromise = ChatMessage.getInstance().countDocuments(query); const [messages, count] = await Promise.all([messagesPromise, countPromise]).catch( @@ -171,6 +217,167 @@ export class AdminHandlers { return 'Done'; } + async getRoomById(call: ParsedRouterRequest): Promise { + const { roomId } = call.request.urlParams as { roomId: string }; + const { populate } = call.request.queryParams as { populate: string[] }; + const room = await ChatRoom.getInstance().findOne( + { _id: roomId }, + undefined, + populate, + ); + if (isNil(room)) throw new GrpcError(status.NOT_FOUND, 'Room does not exist'); + return room; + } + + async getRoomInvitations(call: ParsedRouterRequest): Promise { + const { roomId } = call.request.urlParams as { roomId: string }; + const { skip, limit, sort, populate } = call.request.queryParams as { + skip?: number; + limit?: number; + sort?: string; + populate?: string[]; + }; + const invitations = await InvitationToken.getInstance() + .findMany( + { room: roomId }, + undefined, + skip ?? 0, + limit ?? 10, + sort ?? '-createdAt', + populate, + ) + .catch((e: Error) => { + throw new GrpcError(status.INTERNAL, e.message); + }); + const count = await InvitationToken.getInstance().countDocuments({ + room: roomId, + }); + return { invitations, count }; + } + + async removeFromRoom(call: ParsedRouterRequest): Promise { + const { roomId } = call.request.urlParams as { roomId: string }; + const { users } = call.request.bodyParams as { users: string[] }; + if (!users.length) { + throw new GrpcError( + status.INVALID_ARGUMENT, + 'users field is required and must be a non-empty array', + ); + } + const room = await ChatRoom.getInstance().findOne({ _id: roomId }); + if (isNil(room) || room.deleted) + throw new GrpcError(status.NOT_FOUND, "Room doesn't exists!"); + if (!room.participants.length) + throw new GrpcError(status.CANCELLED, 'Room is already empty!'); + + const unique = Array.from(new Set(users)); + const toBeRemoved = unique.flatMap(user => { + const index = (room.participants as string[]).indexOf(user); + if (index > -1) return user; + else return []; + }); + if (!toBeRemoved.length) + throw new GrpcError(status.INVALID_ARGUMENT, 'Users are not participants of room!'); + + room.participants = room.participants.flatMap(user => { + const index = toBeRemoved.indexOf(user as string); + if (index > -1) return []; + else return user as string; + }); + const participantsLog = await Promise.all( + toBeRemoved.map(user => + ChatParticipantsLog.getInstance().create({ + user, + action: 'remove', + chatRoom: room._id, + }), + ), + ).catch(e => { + throw new GrpcError(status.INTERNAL, e.message); + }); + room.participantsLog.push(...participantsLog); + await ChatRoom.getInstance() + .findByIdAndUpdate(room._id, room) + .catch((e: Error) => { + throw new GrpcError(status.INTERNAL, e.message); + }); + + const config = ConfigController.getInstance().config as Config; + if (!room.participants.length && config.deleteEmptyRooms) { + if (config.auditMode) { + await ChatRoom.getInstance().findByIdAndUpdate(room._id, { + deleted: true, + }); + await ChatMessage.getInstance().updateMany({ room: room._id }, { deleted: true }); + } else { + await ChatRoom.getInstance().deleteOne({ _id: roomId }); + await ChatMessage.getInstance().deleteMany({ room: room._id }); + } + } + return 'OK'; + } + + async addUserToRoom(call: ParsedRouterRequest): Promise { + const { roomId } = call.request.urlParams as { roomId: string }; + const { users } = call.request.bodyParams as { users: string[] }; + + const room = await ChatRoom.getInstance().findOne({ _id: roomId }, undefined, [ + 'creator', + ]); + if (isNil(room)) throw new GrpcError(status.NOT_FOUND, "Room doesn't exist"); + if (room.deleted) throw new GrpcError(status.NOT_FOUND, "Room doesn't exist"); + + const unique = Array.from(new Set(users)); + const found = await User.getInstance().findMany({ + _id: { $in: unique }, + }); + if (found.length !== unique.length) { + throw new GrpcError(status.INVALID_ARGUMENT, "User doesn't exist"); + } + const toBeAdded = found.filter( + user => !(room.participants as string[]).includes(user._id), + ); + + if (!toBeAdded.length) + throw new GrpcError(status.INVALID_ARGUMENT, 'Users are already room members!'); + + const config = ConfigController.getInstance().config as Config; + if (config.explicit_room_joins.enabled) { + const serverConfig = await this.grpcSdk.config.get('router'); + await sendInvitations({ + users: toBeAdded, + sender: room.creator as User, + room, + url: serverConfig.hostUrl, + sendEmail: config.explicit_room_joins.send_email, + sendNotification: config.explicit_room_joins.send_notification, + grpcSdk: this.grpcSdk, + }); + } else { + const participantsLog = await ChatParticipantsLog.getInstance().createMany( + toBeAdded.map(user => ({ + user: user._id, + action: 'added', + chatRoom: room._id, + })), + ); + await ChatRoom.getInstance().findByIdAndUpdate(room._id, { + participants: Array.from( + new Set([ + ...(room.participants as string[]), + ...toBeAdded.map(user => user._id), + ]), + ), + participantsLog: [ + ...room.participantsLog, + ...participantsLog.map(log => log._id), + ], + }); + } + + return 'OK'; + } + private registerAdminRoutes() { this.routingManager.clear(); this.routingManager.route( @@ -181,8 +388,11 @@ export class AdminHandlers { queryParams: { skip: ConduitNumber.Optional, limit: ConduitNumber.Optional, - sort: ConduitString.Optional, + sort: [ConduitString.Optional], search: ConduitString.Optional, + users: [ConduitObjectId.Optional], + deleted: ConduitBoolean.Optional, + populate: [ConduitString.Optional], }, }, new ConduitRouteReturnDefinition('GetRooms', { @@ -198,7 +408,8 @@ export class AdminHandlers { description: `Creates a new chat room.`, bodyParams: { name: ConduitString.Required, - participants: { type: [TYPE.String], required: true }, // handler array check is still required + participants: [ConduitObjectId.Required], // handler array check is still required + creator: ConduitObjectId.Required, }, }, new ConduitRouteReturnDefinition(ChatRoom.name), @@ -225,9 +436,10 @@ export class AdminHandlers { skip: ConduitNumber.Optional, limit: ConduitNumber.Optional, sort: ConduitString.Optional, - senderUser: ConduitString.Optional, - roomId: ConduitString.Optional, + senderUser: ConduitObjectId.Optional, + roomId: ConduitObjectId.Optional, search: ConduitString.Optional, + populate: [ConduitString.Optional], }, }, new ConduitRouteReturnDefinition('GetMessages', { @@ -248,6 +460,72 @@ export class AdminHandlers { new ConduitRouteReturnDefinition('DeleteMessages', 'String'), this.deleteMessages.bind(this), ); + this.routingManager.route( + { + path: '/rooms/:roomId', + action: ConduitRouteActions.GET, + description: `Returns room by id.`, + urlParams: { + roomId: ConduitObjectId.Required, + }, + queryParams: { + populate: [ConduitString.Optional], + }, + }, + new ConduitRouteReturnDefinition('GetRoomById', ChatRoom.name), + this.getRoomById.bind(this), + ); + this.routingManager.route( + { + path: '/invitations/:roomId', + action: ConduitRouteActions.GET, + description: `Returns room invitations.`, + urlParams: { + roomId: ConduitObjectId.Required, + }, + queryParams: { + skip: ConduitNumber.Optional, + limit: ConduitNumber.Optional, + sort: ConduitString.Optional, + populate: [ConduitString.Optional], + }, + }, + new ConduitRouteReturnDefinition('GetInvitationsResponse', { + invitations: [InvitationToken.name], + count: ConduitNumber.Required, + }), + this.getRoomInvitations.bind(this), + ); + this.routingManager.route( + { + path: '/rooms/:roomId/add', + action: ConduitRouteActions.UPDATE, + description: 'Adds users to room', + urlParams: { + roomId: ConduitObjectId.Required, + }, + bodyParams: { + users: [ConduitObjectId.Required], + }, + }, + new ConduitRouteReturnDefinition('RoomAddResponse', 'String'), + this.addUserToRoom.bind(this), + ); + this.routingManager.route( + { + path: '/room/:roomId/remove', + action: ConduitRouteActions.UPDATE, + description: 'Removes users from room', + urlParams: { + roomId: ConduitObjectId.Required, + }, + bodyParams: { + users: [ConduitObjectId.Required], + }, + }, + new ConduitRouteReturnDefinition('RoomLeaveResponse', 'String'), + this.removeFromRoom.bind(this), + ); this.routingManager.registerRoutes(); } diff --git a/modules/chat/src/models/ChatParticipantsLog.schema.ts b/modules/chat/src/models/ChatParticipantsLog.schema.ts index 31469dd5c..6f1de26e5 100644 --- a/modules/chat/src/models/ChatParticipantsLog.schema.ts +++ b/modules/chat/src/models/ChatParticipantsLog.schema.ts @@ -7,7 +7,7 @@ const schema: ConduitModel = { _id: TYPE.ObjectId, action: { type: TYPE.String, - enum: ['add', 'remove', 'create', 'join', 'leave'], + enum: ['added', 'remove', 'create', 'join', 'leave'], required: true, }, user: { @@ -39,7 +39,7 @@ const collectionName = undefined; export class ChatParticipantsLog extends ConduitActiveSchema { private static _instance: ChatParticipantsLog; _id: string; - action: 'add' | 'remove' | 'create' | 'join' | 'leave'; + action: 'added' | 'remove' | 'create' | 'join' | 'leave'; user: string | User; chatRoom: string | ChatRoom; createdAt: Date; diff --git a/modules/chat/src/routes/InvitationRoutes.ts b/modules/chat/src/routes/InvitationRoutes.ts index 5c4feca30..3a055a2b2 100644 --- a/modules/chat/src/routes/InvitationRoutes.ts +++ b/modules/chat/src/routes/InvitationRoutes.ts @@ -11,7 +11,7 @@ import { ConduitString, RoutingManager, } from '@conduitplatform/module-tools'; -import { ChatRoom, InvitationToken } from '../models/index.js'; +import { ChatParticipantsLog, ChatRoom, InvitationToken } from '../models/index.js'; import { isNil } from 'lodash-es'; import { status } from '@grpc/grpc-js'; @@ -122,6 +122,11 @@ export class InvitationRoutes { if (!isNil(invitationTokenDoc) && accepted) { chatRoom.participants.push(user); await ChatRoom.getInstance().findByIdAndUpdate(chatRoom._id, chatRoom); + await ChatParticipantsLog.getInstance().create({ + user: user._id, + action: 'join', + chatRoom: invitationTokenDoc.room as string, + }); message = 'Invitation accepted'; } else { message = 'Invitation declined'; @@ -166,6 +171,11 @@ export class InvitationRoutes { if (!isNil(invitationTokenDoc) && accepted) { (chatRoom.participants as string[]).push(receiver as string); await ChatRoom.getInstance().findByIdAndUpdate(roomId, chatRoom); + await ChatParticipantsLog.getInstance().create({ + user: user._id, + action: 'join', + chatRoom: roomId, + }); message = 'Invitation accepted'; } else { message = 'Invitation declined'; @@ -220,8 +230,9 @@ export class InvitationRoutes { .catch((e: Error) => { throw new GrpcError(status.INTERNAL, e.message); }); - - const count = invitations.length; + const count = await InvitationToken.getInstance().countDocuments({ + receiver: user._id, + }); return { invitations, count }; } diff --git a/modules/chat/src/routes/index.ts b/modules/chat/src/routes/index.ts index 69dda8f3b..d718133dc 100644 --- a/modules/chat/src/routes/index.ts +++ b/modules/chat/src/routes/index.ts @@ -12,6 +12,7 @@ import { } from '@conduitplatform/grpc-sdk'; import { ConduitNumber, + ConduitObjectId, ConduitString, ConfigController, GrpcServer, @@ -39,20 +40,25 @@ export class ChatRoutes { } async registerTemplates() { - this.grpcSdk.config - .get('email') - .then(() => { - const promises = Object.values(templates).map((template: any) => { - return this.grpcSdk.emailProvider!.registerTemplate(template); - }); - return Promise.all(promises); - }) - .then(() => { - ConduitGrpcSdk.Logger.log('Email templates registered'); - }) - .catch(() => { - ConduitGrpcSdk.Logger.error('Internal error while registering email templates'); + if ( + this.grpcSdk.isAvailable('comms') && + this.grpcSdk.comms?.featureAvailable('email') + ) { + const promises = Object.values(templates).map((template: any) => { + return this.grpcSdk.comms?.email!.registerTemplate(template); }); + return Promise.all(promises) + .then(() => { + ConduitGrpcSdk.Logger.log('Email templates registered'); + }) + .catch(() => { + ConduitGrpcSdk.Logger.error('Internal error while registering email templates'); + }); + } else { + ConduitGrpcSdk.Logger.error( + 'Could not register email templates, email not available', + ); + } } async createRoom(call: ParsedRouterRequest): Promise { @@ -66,7 +72,7 @@ export class ChatRoutes { ); } try { - usersToBeAdded = await validateUsersInput(this.grpcSdk, users); + usersToBeAdded = await validateUsersInput(users); } catch (e) { throw new GrpcError(status.INTERNAL, (e as Error).message); } @@ -92,15 +98,15 @@ export class ChatRoutes { participantsLog: [participantsLog._id], })) as ChatRoom; const serverConfig = await this.grpcSdk.config.get('router'); - await sendInvitations( - usersToBeAdded, - user, + await sendInvitations({ + users: usersToBeAdded, + sender: user, room, - serverConfig.hostUrl, - this.sendEmail, - this.sendPushNotification, - this.grpcSdk, - ).catch((e: Error) => { + url: serverConfig.hostUrl, + sendEmail: this.sendEmail, + sendNotification: this.sendPushNotification, + grpcSdk: this.grpcSdk, + }).catch((e: Error) => { throw new GrpcError(status.INTERNAL, e.message); }); } else { @@ -118,7 +124,7 @@ export class ChatRoutes { }, ...users.map((userId: string) => ({ user: userId, - action: 'join' as 'join', + action: 'join', chatRoom: room._id, })), ]); @@ -153,7 +159,7 @@ export class ChatRoutes { throw new GrpcError(status.NOT_FOUND, "Room doesn't exist"); } try { - usersToBeAdded = await validateUsersInput(this.grpcSdk, users); + usersToBeAdded = await validateUsersInput(users); } catch (e) { throw new GrpcError(status.INTERNAL, (e as Error).message); } @@ -169,15 +175,15 @@ export class ChatRoutes { const config = await this.grpcSdk.config.get('chat'); if (config.explicit_room_joins.enabled) { const serverConfig = await this.grpcSdk.config.get('router'); - const ret = await sendInvitations( - usersToBeAdded, - user, + const ret = await sendInvitations({ + users: usersToBeAdded, + sender: user, room, - serverConfig.hostUrl, - this.sendEmail, - this.sendPushNotification, - this.grpcSdk, - ).catch((e: Error) => { + url: serverConfig.hostUrl, + sendEmail: this.sendEmail, + sendNotification: this.sendPushNotification, + grpcSdk: this.grpcSdk, + }).catch((e: Error) => { throw new GrpcError(status.INTERNAL, e.message); }); return ret!; @@ -185,7 +191,7 @@ export class ChatRoutes { const participantsLog = await ChatParticipantsLog.getInstance().createMany( users.map((userId: string) => ({ user: userId, - action: 'join' as 'join', + action: 'added', chatRoom: room._id, })), ); @@ -223,7 +229,7 @@ export class ChatRoutes { room.participants.splice(index, 1); const participantsLog = await ChatParticipantsLog.getInstance().create({ user: user._id, - action: 'leave' as 'leave', + action: 'leave', chatRoom: room._id, }); @@ -263,6 +269,57 @@ export class ChatRoutes { return 'Ok'; } + async removeFromRoom(call: ParsedRouterRequest): Promise { + const { roomId } = call.request.urlParams as { roomId: string }; + const { user } = call.request.context as { user: User }; + const { users } = call.request.bodyParams as { users: string[] }; + + if (users.length === 0) { + throw new GrpcError( + status.INVALID_ARGUMENT, + 'Users is required and must be a non-empty array', + ); + } + + const room = await ChatRoom.getInstance().findOne({ _id: roomId }); + if (isNil(room)) throw new GrpcError(status.NOT_FOUND, "Room doesn't exist"); + + if (room.creator !== user._id) + throw new GrpcError( + status.PERMISSION_DENIED, + "User doesn't have permissions to remove users from room!", + ); + + const index = (room.participants as string[]).indexOf(user._id); + if (index > -1) + throw new GrpcError(status.INVALID_ARGUMENT, "Users can't remove self from room!"); + + for (const user of users) { + if ((room.participants as string[]).indexOf(user) === -1) + throw new GrpcError(status.NOT_FOUND, 'User is not participant of room!'); + } + + room.participants = (room.participants as string[]).flatMap(participant => { + if (users.indexOf(participant) > -1) return []; + return participant; + }); + users.map(async user => { + const log = await ChatParticipantsLog.getInstance().create({ + user: user, + action: 'remove', + chatRoom: room._id, + }); + room.participantsLog.push(log); + }); + await ChatRoom.getInstance() + .findByIdAndUpdate(room._id, room) + .catch((e: Error) => { + throw new GrpcError(status.INTERNAL, e.message); + }); + + return 'OK'; + } + async getMessages(call: ParsedRouterRequest): Promise { const { roomId, skip, limit } = call.request.params; const { user } = call.request.context; @@ -507,7 +564,7 @@ export class ChatRoutes { description: `Creates a new room.`, bodyParams: { roomName: ConduitString.Required, - users: [TYPE.String], + users: [ConduitObjectId.Required], }, middlewares: ['authMiddleware'], }, @@ -523,7 +580,7 @@ export class ChatRoutes { action: ConduitRouteActions.UPDATE, description: `Adds users to a chat room.`, urlParams: { - roomId: ConduitString.Required, + roomId: ConduitObjectId.Required, }, bodyParams: { users: [TYPE.String], @@ -538,9 +595,9 @@ export class ChatRoutes { { path: '/leave/:roomId', action: ConduitRouteActions.UPDATE, - description: `Removes current user from a chat room.`, + description: `Context user leaves chat room.`, urlParams: { - roomId: ConduitString.Required, + roomId: ConduitObjectId.Required, }, middlewares: ['authMiddleware'], }, @@ -548,13 +605,30 @@ export class ChatRoutes { this.leaveRoom.bind(this), ); + this._routingManager.route( + { + path: '/room/:roomId/remove', + action: ConduitRouteActions.UPDATE, + description: `Room Creator removes users from chat room.`, + urlParams: { + roomId: ConduitObjectId.Required, + }, + bodyParams: { + users: [ConduitObjectId.Required], + }, + middlewares: ['authMiddleware'], + }, + new ConduitRouteReturnDefinition('RemoveFromRoomResponse', 'String'), + this.removeFromRoom.bind(this), + ); + this._routingManager.route( { path: '/rooms/:id', action: ConduitRouteActions.GET, description: `Returns a chat room.`, urlParams: { - id: ConduitString.Required, + id: ConduitObjectId.Required, }, middlewares: ['authMiddleware'], }, diff --git a/modules/chat/src/utils/index.ts b/modules/chat/src/utils/index.ts index 25090448a..5ef431655 100644 --- a/modules/chat/src/utils/index.ts +++ b/modules/chat/src/utils/index.ts @@ -4,7 +4,7 @@ import { ConduitGrpcSdk, GrpcError, UntypedArray } from '@conduitplatform/grpc-s import { ChatRoom, InvitationToken, User } from '../models/index.js'; import { v4 as uuid } from 'uuid'; -export async function validateUsersInput(grpcSdk: ConduitGrpcSdk, users: UntypedArray) { +export async function validateUsersInput(users: UntypedArray) { const uniqueUsers = Array.from(new Set(users)); let errorMessage: string | null = null; const usersToBeAdded = (await User.getInstance() @@ -28,15 +28,16 @@ export async function validateUsersInput(grpcSdk: ConduitGrpcSdk, users: Untyped return usersToBeAdded; } -export async function sendInvitations( - users: User[], - sender: User, - room: ChatRoom, - url: string, - sendEmail: boolean, - sendNotification: boolean, - grpcSdk: ConduitGrpcSdk, -) { +export async function sendInvitations(args: { + users: User[]; + sender: User; + room: ChatRoom; + url: string; + sendEmail: boolean; + sendNotification: boolean; + grpcSdk: ConduitGrpcSdk; +}) { + const { room, users, sender, url, sendEmail, sendNotification, grpcSdk } = args; const roomId = room._id; for (const invitedUser of users) { const invitationsCount = await InvitationToken.getInstance().countDocuments({ @@ -56,14 +57,18 @@ export async function sendInvitations( token: uuid(), room: roomId, }); - if (sendEmail) { + if ( + sendEmail && + grpcSdk.isAvailable('comms') && + grpcSdk.comms?.featureAvailable('email') + ) { const result = { invitationToken, hostUrl: url }; const acceptLink = `${result.hostUrl}/hook/chat/invitations/accept/${result.invitationToken.token}`; const declineLink = `${result.hostUrl}/hook/chat/invitations/decline/${result.invitationToken.token}`; const roomName = room.name; const userName = sender.email; - await grpcSdk - .emailProvider!.sendEmail('ChatRoomInvitation', { + await grpcSdk.comms?.email + ?.sendEmail('ChatRoomInvitation', { email: invitedUser.email, variables: { acceptLink, @@ -76,10 +81,14 @@ export async function sendInvitations( throw new Error(e.message); }); } - if (sendNotification) { + if ( + sendNotification && + grpcSdk.isAvailable('comms') && + grpcSdk.comms?.featureAvailable('pushNotifications') + ) { const body = `User ${sender._id} has invited you to join in room ${room.name}`; const title = 'You have an invitation request!'; - await grpcSdk + await grpcSdk.comms .pushNotifications!.sendNotification(invitedUser._id, title, body) .catch((e: Error) => { throw new Error(e.message); @@ -88,15 +97,3 @@ export async function sendInvitations( } return 'Invitations sent'; } - -export function populateArray(pop: string | string[] | undefined) { - if (!pop) return pop; - if (typeof pop === 'string' && pop.indexOf(',') !== -1) { - pop = pop.split(','); - } else if (Array.isArray(pop)) { - return pop; - } else { - pop = [pop]; - } - return pop; -} diff --git a/modules/email/Dockerfile b/modules/comms/Dockerfile similarity index 100% rename from modules/email/Dockerfile rename to modules/comms/Dockerfile diff --git a/modules/email/README.mdx b/modules/comms/README.mdx similarity index 100% rename from modules/email/README.mdx rename to modules/comms/README.mdx diff --git a/modules/email/build.sh b/modules/comms/build.sh similarity index 100% rename from modules/email/build.sh rename to modules/comms/build.sh diff --git a/modules/email/package.json b/modules/comms/package.json similarity index 73% rename from modules/email/package.json rename to modules/comms/package.json index 6f1d1b777..906444cfc 100644 --- a/modules/email/package.json +++ b/modules/comms/package.json @@ -1,5 +1,5 @@ { - "name": "@conduitplatform/email", + "name": "@conduitplatform/comms", "version": "1.0.1", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -14,7 +14,7 @@ "prebuild": "npm run generateTypes", "build": "rimraf dist && tsc", "prepare": "npm run build", - "build:docker": "docker build -t ghcr.io/conduitplatform/email:latest -f ./Dockerfile ../../ && docker push ghcr.io/conduitplatform/email:latest", + "build:docker": "docker build -t ghcr.io/conduitplatform/comms:latest -f ./Dockerfile ../../ && docker push ghcr.io/conduitplatform/comms:latest", "generateTypes": "sh build.sh" }, "directories": { @@ -28,34 +28,44 @@ }, "license": "ISC", "dependencies": { + "@aws-sdk/client-sns": "^3.627.1", "@conduitplatform/grpc-sdk": "*", "@conduitplatform/module-tools": "*", "@grpc/grpc-js": "^1.10.9", "@grpc/proto-loader": "^0.7.6", + "@onesignal/node-onesignal": "^1.0.0-beta9", "@sendgrid/client": "^8.0.0", "@types/nodemailer-sendgrid": "^1.0.3", "await-to-js": "^3.0.0", "axios": "^1.7.4", + "bluebird": "^3.7.2", "bullmq": "^5.12.14", + "clicksend": "^5.0.79", "convict": "^6.2.4", "escape-string-regexp": "^4.0.0", + "firebase-admin": "^12.4.0", + "form-data": "^4.0.0", "handlebars": "^4.7.8", "lodash-es": "^4.17.21", - "mailgun-js": "^0.22.0", + "mailgun.js": "^10.2.3", "mandrill-api": "^1.0.45", + "messagebird": "^4.0.1", "nodemailer": "^6.9.10", "nodemailer-mailgun-transport": "^2.1.5", "nodemailer-mandrill-transport": "^1.2.0", - "nodemailer-sendgrid": "^1.0.3" + "nodemailer-sendgrid": "^1.0.3", + "otp-generator": "^4.0.1", + "twilio": "5.3.0" }, "devDependencies": { + "@types/bluebird": "^3.5.42", "@types/convict": "^6.1.6", "@types/lodash-es": "^4.17.12", - "@types/mailgun-js": "^0.22.18", "@types/mandrill-api": "^1.0.34", "@types/node": "20.11.24", "@types/nodemailer": "^6.4.14", "@types/nodemailer-mailgun-transport": "^1.4.3", + "@types/otp-generator": "^4.0.2", "@types/smtp-server": "^3.5.7", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", diff --git a/modules/comms/src/Comms.ts b/modules/comms/src/Comms.ts new file mode 100644 index 000000000..93d3083b0 --- /dev/null +++ b/modules/comms/src/Comms.ts @@ -0,0 +1,143 @@ +import { + ConduitGrpcSdk, + GrpcCallback, + GrpcRequest, + HealthCheckStatus, +} from '@conduitplatform/grpc-sdk'; +import AppConfigSchema, { Config } from './config/index.js'; +import { AdminHandlers } from './admin/index.js'; +import path from 'path'; +import metricsSchema from './metrics/index.js'; +import { ManagedModule, ServiceFunction } from '@conduitplatform/module-tools'; +import { fileURLToPath } from 'node:url'; +import Sms from './modules/sms/Sms.js'; +import { CommService } from './interfaces/CommService.js'; +import { ClientRouteHandlers } from './router/index.js'; +import { FeatureAvailableRequest, FeatureAvailableResponse } from './protoTypes/comms.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default class Comms extends ManagedModule { + configSchema = AppConfigSchema; + protected metricsSchema = metricsSchema; + private readonly smsService: CommService; + private readonly emailService: CommService; + private readonly pushService: CommService; + + constructor() { + super('comms'); + this.updateHealth(HealthCheckStatus.UNKNOWN, true); + AdminHandlers.getInstance(this.grpcServer, this.grpcSdk); + ClientRouteHandlers.getInstance(this.grpcServer, this.grpcSdk); + this.smsService = Sms.getInstance(this.grpcSdk); + // this.emailService = Email.getInstance(this.grpcSdk); + // this.pushService = Sms.getInstance(this.grpcSdk); + this.service = { + protoPath: path.resolve(__dirname, 'comms.proto'), + protoDescription: 'comms', + functions: { + Comms: { + featureAvailable: this.featureAvailable.bind(this), + }, + Sms: { + ...(this.smsService.rpcFunctions as { [key: string]: ServiceFunction }), + }, + Email: { + ...(this.emailService.rpcFunctions as { [key: string]: ServiceFunction }), + }, + PushNotifications: { + ...(this.pushService.rpcFunctions as { [key: string]: ServiceFunction }), + }, + }, + }; + } + + async preConfig(config: Config) { + let modifiedConfig = config; + if (!config.migrationComplete) { + const sms = await this.grpcSdk.config.get('sms'); + const email = await this.grpcSdk.config.get('email'); + const push = await this.grpcSdk.config.get('pushNotifications'); + modifiedConfig = { + ...config, + sms: { + ...sms, + }, + email: { + ...email, + }, + push: { + ...push, + }, + migrationComplete: true, + }; + } + + modifiedConfig = + (await this.smsService.preConfig?.(modifiedConfig)) ?? modifiedConfig; + modifiedConfig = + (await this.emailService.preConfig?.(modifiedConfig)) ?? modifiedConfig; + modifiedConfig = + (await this.pushService.preConfig?.(modifiedConfig)) ?? modifiedConfig; + return modifiedConfig; + } + + async onServerStart() { + await this.smsService.onServerStart?.(); + await this.emailService.onServerStart?.(); + await this.pushService.onServerStart?.(); + } + + async onConfig() { + await this.smsService.onConfig?.(); + await this.emailService.onConfig?.(); + await this.pushService.onConfig?.(); + let oneHealthy = false; + if (this.smsService.health === HealthCheckStatus.SERVING) { + oneHealthy = true; + } + if (this.emailService.health === HealthCheckStatus.SERVING) { + oneHealthy = true; + } + if (this.pushService.health === HealthCheckStatus.SERVING) { + oneHealthy = true; + } + if (oneHealthy) { + this.updateHealth(HealthCheckStatus.SERVING); + } else { + this.updateHealth(HealthCheckStatus.NOT_SERVING); + } + await AdminHandlers.getInstance().registerAdminRoutes(); + + this.grpcSdk + .waitForExistence('router') + .then(() => ClientRouteHandlers.getInstance().registerRoutes()) + .catch(e => { + ConduitGrpcSdk.Logger.error(e.message); + }); + } + + async initializeMetrics() { + await this.smsService.initializeMetrics?.(); + await this.emailService.initializeMetrics?.(); + await this.pushService.initializeMetrics?.(); + } + + // gRPC Service + async featureAvailable( + call: GrpcRequest, + callback: GrpcCallback, + ) { + const feature = call.request.serviceName; + let available = false; + if (feature === 'sms') { + available = this.smsService.health === HealthCheckStatus.SERVING; + } else if (feature === 'email') { + available = this.emailService.health === HealthCheckStatus.SERVING; + } else if (feature === 'pushNotifications') { + available = this.pushService.health === HealthCheckStatus.SERVING; + } + callback(null, { available }); + } +} diff --git a/modules/comms/src/admin/index.ts b/modules/comms/src/admin/index.ts new file mode 100644 index 000000000..a8765aef0 --- /dev/null +++ b/modules/comms/src/admin/index.ts @@ -0,0 +1,33 @@ +import { ConduitGrpcSdk } from '@conduitplatform/grpc-sdk'; +import { GrpcServer, RoutingManager } from '@conduitplatform/module-tools'; +import Sms from '../modules/sms/Sms.js'; +import Email from '../modules/email/Email.js'; +import PushNotifications from '../modules/push/PushNotifications.js'; + +export class AdminHandlers { + private readonly routingManager: RoutingManager; + private static instance: AdminHandlers | null = null; + + private constructor( + readonly server: GrpcServer, + readonly grpcSdk: ConduitGrpcSdk, + ) { + this.routingManager = new RoutingManager(grpcSdk.admin, server); + } + + public static getInstance(server?: GrpcServer, grpcSdk?: ConduitGrpcSdk) { + if (!AdminHandlers.instance) { + if (!server || !grpcSdk) throw new Error('Server and grpcSdk must be provided'); + AdminHandlers.instance = new AdminHandlers(server!, grpcSdk!); + } + return AdminHandlers.instance; + } + + async registerAdminRoutes() { + this.routingManager.clear(); + await Sms.getInstance().registerAdminRoutes(this.routingManager); + await Email.getInstance().registerAdminRoutes(this.routingManager); + await PushNotifications.getInstance().registerAdminRoutes(this.routingManager); + await this.routingManager.registerRoutes(); + } +} diff --git a/modules/comms/src/comms.proto b/modules/comms/src/comms.proto new file mode 100644 index 000000000..023a9789b --- /dev/null +++ b/modules/comms/src/comms.proto @@ -0,0 +1,156 @@ +syntax = 'proto3'; +package comms; + +message FeatureAvailableRequest{ + string serviceName = 1; +} + +message FeatureAvailableResponse{ + bool available = 1; +} + +service Comms { + rpc FeatureAvailable(FeatureAvailableRequest) returns (FeatureAvailableResponse); +} + +//------ SMS ------ +message SendSmsRequest { + string to = 1; + string message = 2; +} + +message SendSmsResponse { + string message = 1; +} + +message SendVerificationCodeRequest { + string to = 1; +} + +message SendVerificationCodeResponse { + string verificationSid = 1; +} + +message VerifyRequest { + string verificationSid = 1; + string code = 2; +} + +message VerifyResponse { + bool verified = 1; +} + +service Sms { + rpc SendSms(SendSmsRequest) returns (SendSmsResponse); + rpc SendVerificationCode(SendVerificationCodeRequest) returns (SendVerificationCodeResponse); + rpc Verify(VerifyRequest) returns (VerifyResponse); + +} +//------ EMAIL ------ +message RegisterTemplateRequest { + string name = 1; + string subject = 2; + string body = 3; + repeated string variables = 4; + optional string sender = 5; +} + +message RegisterTemplateResponse { + string template = 1; +} + +message SendEmailRequest { + string templateName = 1; + SendEmailParams params = 2; + message SendEmailParams { + string email = 1; + string variables = 2; + optional string sender = 3; + repeated string cc = 4; + optional string replyTo = 5; + repeated string attachments = 6; + } +} + +message SendEmailResponse { + string sentMessageInfo = 1; +} + +message ResendEmailRequest { + string emailRecordId = 1; +} + +message ResendEmailResponse { + string sentMessageInfo = 1; +} + +message GetEmailStatusRequest { + string messageId = 1; +} + +message GetEmailStatusResponse { + string statusInfo = 1; +} + +service Email { + rpc RegisterTemplate(RegisterTemplateRequest) returns (RegisterTemplateResponse); + rpc SendEmail(SendEmailRequest) returns (SendEmailResponse); + rpc ResendEmail(ResendEmailRequest) returns (ResendEmailResponse); + rpc GetEmailStatus(GetEmailStatusRequest) returns (GetEmailStatusResponse); +} + +//------ PushNotifications ------ + +message SetNotificationTokenRequest { + string token = 1; + string platform = 2; + string userId = 3; +} + +message SetNotificationTokenResponse { + string newTokenDocument = 1; +} + +message GetNotificationTokensRequest { + string userId = 1; +} + +message GetNotificationTokensResponse { + repeated string tokenDocuments = 1; +} + +message SendNotificationRequest { + string sendTo = 1; + optional string title = 2; + optional string platform = 3; + optional string body = 4; + optional string data = 5; + optional bool doNotStore = 6; + optional bool isSilent = 7; +} + +message SendNotificationToManyDevicesRequest { + repeated string sendTo = 1; + optional string title = 2; + optional string body = 3; + optional string data = 4; + optional string platform = 5; + optional bool doNotStore = 6; + optional bool isSilent = 7; +} + +message SendManyNotificationsRequest { + repeated SendNotificationRequest notifications = 1; +} + +message SendNotificationResponse { + string message = 1; +} + +service PushNotifications { + rpc SetNotificationToken(SetNotificationTokenRequest) returns (SetNotificationTokenResponse); + rpc GetNotificationTokens(GetNotificationTokensRequest) returns (GetNotificationTokensResponse); + rpc SendNotification(SendNotificationRequest) returns (SendNotificationResponse); + rpc SendNotificationToManyDevices(SendNotificationToManyDevicesRequest) returns (SendNotificationResponse); + rpc SendManyNotifications(SendManyNotificationsRequest) returns (SendNotificationResponse); +} diff --git a/modules/comms/src/config/comms.ts b/modules/comms/src/config/comms.ts new file mode 100644 index 000000000..41df2be98 --- /dev/null +++ b/modules/comms/src/config/comms.ts @@ -0,0 +1,7 @@ +export default { + doc: 'The options for the conduit comms service', + migrationComplete: { + format: 'Boolean', + default: false, + }, +}; diff --git a/modules/comms/src/config/email.ts b/modules/comms/src/config/email.ts new file mode 100644 index 000000000..55da034b6 --- /dev/null +++ b/modules/comms/src/config/email.ts @@ -0,0 +1,130 @@ +export default { + email: { + doc: 'The options for the conduit email provider', + active: { + format: 'Boolean', + default: false, + }, + transport: { + format: 'String', + default: 'smtp', + }, + sendingDomain: { + format: 'String', + default: 'conduit.com', + }, + transportSettings: { + mailgun: { + apiKey: { + doc: 'The email service API key', + format: 'String', + default: '', + }, + host: { + doc: 'The host for email service', + format: 'String', + default: '', + }, + proxy: { + doc: 'The email proxy', + format: 'String', + default: '', + }, + }, + smtp: { + port: { + doc: 'The port the SMTP server is listening on', + format: 'Number', + default: -1, + }, + secure: { + format: 'Boolean', + default: false, + }, + ignoreTls: { + format: 'Boolean', + default: false, + }, + host: { + doc: 'The SMTP server address', + format: 'String', + default: '', + }, + auth: { + username: { + format: 'String', + default: '', + }, + password: { + format: 'String', + default: '', + }, + method: { + format: 'String', + default: 'PLAIN', + }, + }, + }, + mandrill: { + apiKey: { + doc: 'The Mandrill API key', + format: 'String', + default: '', + }, + }, + sendgrid: { + residency: { + doc: 'Sets the host for sendgrid provider. SendGrid Default: global', + format: 'String', + default: '', + }, + apiKey: { + doc: 'The SendGrid API key', + format: 'String', + default: '', + }, + }, + }, + storeEmails: { + enabled: { + doc: 'Defines if sent email info should be stored in database', + format: 'Boolean', + default: false, + }, + storage: { + enabled: { + doc: 'Defines if email content should be stored in storage', + format: 'Boolean', + default: false, + }, + container: { + doc: 'The storage container for emails', + format: 'String', + default: 'conduit', + }, + folder: { + doc: 'The storage folder for emails', + format: 'String', + default: 'cnd-stored-emails', + }, + }, + cleanupSettings: { + enabled: { + doc: 'Settings for deleting old stored emails', + format: 'Boolean', + default: false, + }, + repeat: { + doc: 'Time in milliseconds to repeat the cleanup job', + format: 'Number', + default: 6 * 60 * 60 * 1000, + }, + limit: { + doc: 'Amount of stored emails to be deleted upon cleanup', + format: 'Number', + default: 100, + }, + }, + }, + }, +}; diff --git a/modules/comms/src/config/index.ts b/modules/comms/src/config/index.ts new file mode 100644 index 000000000..c44d2269b --- /dev/null +++ b/modules/comms/src/config/index.ts @@ -0,0 +1,16 @@ +import convict from 'convict'; +import emailConfig from './email.js'; +import pushConfig from './push.js'; +import smsConfig from './sms.js'; +import generalConfig from './comms.js'; + +const AppConfigSchema = { + ...generalConfig, + ...emailConfig, + ...pushConfig, + ...smsConfig, +}; +const config = convict(AppConfigSchema); +const configProperties = config.getProperties(); +export type Config = typeof configProperties; +export default AppConfigSchema; diff --git a/modules/comms/src/config/push.ts b/modules/comms/src/config/push.ts new file mode 100644 index 000000000..9e1cc13af --- /dev/null +++ b/modules/comms/src/config/push.ts @@ -0,0 +1,32 @@ +export default { + push: { + providerName: { + format: 'String', + default: 'basic', + }, + firebase: { + projectId: { + format: 'String', + default: '', + }, + privateKey: { + format: 'String', + default: '', + }, + clientEmail: { + format: 'String', + default: '', + }, + }, + onesignal: { + appId: { + format: 'String', + default: '', + }, + apiKey: { + format: 'String', + default: '', + }, + }, + }, +}; diff --git a/modules/comms/src/config/sms.ts b/modules/comms/src/config/sms.ts new file mode 100644 index 000000000..058b701a9 --- /dev/null +++ b/modules/comms/src/config/sms.ts @@ -0,0 +1,71 @@ +export default { + sms: { + doc: 'The options for the conduit sms provider', + active: { + format: 'Boolean', + default: false, + }, + providerName: { + format: 'String', + default: 'twilio', + }, + twilio: { + phoneNumber: { + format: 'String', + default: '', + }, + accountSID: { + format: 'String', + default: '', + }, + authToken: { + format: 'String', + default: '', + }, + verify: { + active: { + format: 'Boolean', + default: false, + }, + serviceSid: { + format: 'String', + default: '', + }, + }, + }, + awsSns: { + region: { + format: 'String', + default: '', + }, + accessKeyId: { + format: 'String', + default: '', + }, + secretAccessKey: { + format: 'String', + default: '', + }, + }, + messageBird: { + accessKeyId: { + format: 'String', + default: '', + }, + originatorName: { + format: 'String', + default: '', + }, + }, + clickSend: { + username: { + format: 'String', + default: '', + }, + clicksendApiKey: { + format: 'String', + default: '', + }, + }, + }, +}; diff --git a/modules/comms/src/index.ts b/modules/comms/src/index.ts new file mode 100644 index 000000000..efb4e627d --- /dev/null +++ b/modules/comms/src/index.ts @@ -0,0 +1,4 @@ +import CommsModule from './Comms.js'; + +const comms = new CommsModule(); +comms.start(); diff --git a/modules/comms/src/interfaces/CommService.ts b/modules/comms/src/interfaces/CommService.ts new file mode 100644 index 000000000..95e2b5b1a --- /dev/null +++ b/modules/comms/src/interfaces/CommService.ts @@ -0,0 +1,14 @@ +import { ConduitService, RoutingManager } from '@conduitplatform/module-tools'; +import { HealthCheckStatus } from '@conduitplatform/grpc-sdk'; + +export interface CommService { + preConfig?: (config: any) => Promise; + onConfig?: () => Promise; + onServerStart?: () => Promise; + initializeMetrics?: () => Promise; + registerRoutes?: (_routingManager: RoutingManager) => Promise | void; + registerAdminRoutes?: (_routingManager: RoutingManager) => Promise | void; + + get rpcFunctions(): ConduitService['functions']; + get health(): HealthCheckStatus; +} diff --git a/modules/email/src/metrics/index.ts b/modules/comms/src/metrics/index.ts similarity index 54% rename from modules/email/src/metrics/index.ts rename to modules/comms/src/metrics/index.ts index 715c87309..68438ea4d 100644 --- a/modules/email/src/metrics/index.ts +++ b/modules/comms/src/metrics/index.ts @@ -15,4 +15,18 @@ export default { help: 'Tracks the total number of emails sent', }, }, + pushNotifications: { + type: MetricType.Counter, + config: { + name: 'push_notifications_sent_total', + help: 'Tracks the total number of push notifications sent', + }, + }, + smsSent: { + type: MetricType.Counter, + config: { + name: 'sms_sent_total', + help: 'Tracks the total number of sms sent', + }, + }, }; diff --git a/modules/email/src/Email.ts b/modules/comms/src/modules/email/Email.ts similarity index 78% rename from modules/email/src/Email.ts rename to modules/comms/src/modules/email/Email.ts index c14085e30..36275a2b2 100644 --- a/modules/email/src/Email.ts +++ b/modules/comms/src/modules/email/Email.ts @@ -6,7 +6,6 @@ import { HealthCheckStatus, } from '@conduitplatform/grpc-sdk'; import path from 'path'; -import AppConfigSchema, { Config } from './config/index.js'; import { AdminHandlers } from './admin/index.js'; import { EmailService } from './services/email.service.js'; import { EmailProvider } from './email-provider/index.js'; @@ -23,29 +22,21 @@ import { ResendEmailResponse, SendEmailRequest, SendEmailResponse, -} from './protoTypes/email.js'; -import metricsSchema from './metrics/index.js'; -import { ConfigController, ManagedModule } from '@conduitplatform/module-tools'; +} from '../../protoTypes/comms.js'; +import { ConfigController, RoutingManager } from '@conduitplatform/module-tools'; import { ISendEmailParams } from './interfaces/index.js'; -import { fileURLToPath } from 'node:url'; import { Queue, Worker } from 'bullmq'; import { Cluster, Redis } from 'ioredis'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +import { CommService } from '../../interfaces/CommService.js'; +import { Config } from '../../config/index.js'; -export default class Email extends ManagedModule { - configSchema = AppConfigSchema; - service = { - protoPath: path.resolve(__dirname, 'email.proto'), - protoDescription: 'email.Email', - functions: { - registerTemplate: this.registerTemplate.bind(this), - sendEmail: this.sendEmail.bind(this), - resendEmail: this.resendEmail.bind(this), - getEmailStatus: this.getEmailStatus.bind(this), - }, +export default class Email implements CommService { + readonly functions = { + registerTemplate: this.registerTemplate.bind(this), + sendEmail: this.sendEmail.bind(this), + resendEmail: this.resendEmail.bind(this), + getEmailStatus: this.getEmailStatus.bind(this), }; - protected metricsSchema = metricsSchema; private isRunning: boolean = false; private adminRouter: AdminHandlers; private database: DatabaseProvider; @@ -54,9 +45,29 @@ export default class Email extends ManagedModule { private redisConnection: Redis | Cluster; private emailCleanupQueue: Queue | null = null; - constructor() { - super('email'); - this.updateHealth(HealthCheckStatus.UNKNOWN, true); + private static _instance: Email; + private _health: HealthCheckStatus = HealthCheckStatus.UNKNOWN; + + private constructor(private readonly grpcSdk: ConduitGrpcSdk) {} + + static getInstance(grpcSdk?: ConduitGrpcSdk) { + if (!Email._instance) { + if (!grpcSdk) throw new Error('GrpcSdk must be provided'); + Email._instance = new Email(grpcSdk); + } + return Email._instance; + } + + registerAdminRoutes(routingManager: RoutingManager) { + this.adminRouter.registerAdminRoutes(routingManager); + } + + get rpcFunctions() { + return this.functions; + } + + get health() { + return this._health; } async onServerStart() { @@ -68,15 +79,15 @@ export default class Email extends ManagedModule { } async preConfig(config: Config) { - if (config.transportSettings?.sendgrid.hasOwnProperty('apiUser')) { + if (config.email.transportSettings?.sendgrid.hasOwnProperty('apiUser')) { delete ( config as Config & { transportSettings: { sendgrid: { apiUser?: string } } } ).transportSettings.sendgrid.apiUser; } if ( - isNil(config.active) || - isNil(config.transport) || - isNil(config.transportSettings) + isNil(config.email.active) || + isNil(config.email.transport) || + isNil(config.email.transportSettings) ) { throw new Error('Invalid configuration given'); } @@ -84,23 +95,26 @@ export default class Email extends ManagedModule { } async onConfig() { - if (!ConfigController.getInstance().config.active) { - this.updateHealth(HealthCheckStatus.NOT_SERVING); + if (!ConfigController.getInstance().config.email.active) { + this._health = HealthCheckStatus.NOT_SERVING; } else { if (!this.isRunning) { await this.initEmailProvider(); this.emailService = new EmailService(this.grpcSdk, this.emailProvider); - this.adminRouter = new AdminHandlers(this.grpcServer, this.grpcSdk); + this.adminRouter = new AdminHandlers(this.grpcSdk); this.adminRouter.setEmailService(this.emailService); this.isRunning = true; } else { await this.initEmailProvider(ConfigController.getInstance().config); this.emailService.updateProvider(this.emailProvider); } - this.updateHealth(HealthCheckStatus.SERVING); + this._health = HealthCheckStatus.SERVING; const config = ConfigController.getInstance().config as Config; - if (config.storeEmails.storage.enabled && !this.grpcSdk.isAvailable('storage')) { + if ( + config.email.storeEmails.storage.enabled && + !this.grpcSdk.isAvailable('storage') + ) { ConduitGrpcSdk.Logger.warn( 'Failed to enable email storing. Storage module not serving.', ); @@ -150,7 +164,7 @@ export default class Email extends ManagedModule { cc: call.request.params!.cc, replyTo: call.request.params!.replyTo, attachments: call.request.params!.attachments, - sendingDomain: emailConfig?.sendingDomain ?? '', + sendingDomain: emailConfig?.email.sendingDomain ?? '', }; let errorMessage: string | null = null; @@ -214,7 +228,10 @@ export default class Email extends ManagedModule { connection: this.redisConnection, }); await this.emailCleanupQueue.drain(true); - if (!config.storeEmails.enabled || !config.storeEmails.cleanupSettings.enabled) { + if ( + !config.email.storeEmails.enabled || + !config.email.storeEmails.cleanupSettings.enabled + ) { await this.emailCleanupQueue.close(); return; } @@ -249,12 +266,12 @@ export default class Email extends ManagedModule { await this.emailCleanupQueue.add( 'cleanup', { - limit: config.storeEmails.cleanupSettings.limit, - deleteStorageFiles: config.storeEmails.storage.enabled, + limit: config.email.storeEmails.cleanupSettings.limit, + deleteStorageFiles: config.email.storeEmails.storage.enabled, }, { repeat: { - every: config.storeEmails.cleanupSettings.repeat, + every: config.email.storeEmails.cleanupSettings.repeat, }, }, ); diff --git a/modules/email/src/admin/index.ts b/modules/comms/src/modules/email/admin/index.ts similarity index 94% rename from modules/email/src/admin/index.ts rename to modules/comms/src/modules/email/admin/index.ts index c71f800ce..25f90d09c 100644 --- a/modules/email/src/admin/index.ts +++ b/modules/comms/src/modules/email/admin/index.ts @@ -14,7 +14,6 @@ import { ConduitJson, ConduitNumber, ConduitString, - GrpcServer, RoutingManager, } from '@conduitplatform/module-tools'; import { status } from '@grpc/grpc-js'; @@ -23,23 +22,16 @@ import { isNil } from 'lodash-es'; import { getHandleBarsValues } from '../email-provider/utils/index.js'; import { EmailService } from '../services/email.service.js'; import { EmailRecord, EmailTemplate } from '../models/index.js'; -import { Config } from '../config/index.js'; import { Template } from '../email-provider/interfaces/Template.js'; import { TemplateDocument } from '../email-provider/interfaces/TemplateDocument.js'; import escapeStringRegexp from 'escape-string-regexp'; +import { Config } from '../../../config/index.js'; export class AdminHandlers { private emailService: EmailService; - private readonly routingManager: RoutingManager; - - constructor( - private readonly server: GrpcServer, - private readonly grpcSdk: ConduitGrpcSdk, - ) { - this.routingManager = new RoutingManager(grpcSdk.admin, server); - this.registerAdminRoutes(); - } + + constructor(private readonly grpcSdk: ConduitGrpcSdk) {} setEmailService(emailService: EmailService) { this.emailService = emailService; @@ -354,7 +346,7 @@ export class AdminHandlers { const emailConfig: Config = await this.grpcSdk.config .get('email') .catch(() => ConduitGrpcSdk.Logger.error('Failed to get sending domain')); - sender = sender + `@${emailConfig?.sendingDomain ?? 'conduit.com'}`; + sender = sender + `@${emailConfig?.email.sendingDomain ?? 'conduit.com'}`; } if (templateName) { const templateFound = await EmailTemplate.getInstance().findOne({ @@ -434,11 +426,10 @@ export class AdminHandlers { return { records, count }; } - private registerAdminRoutes() { - this.routingManager.clear(); - this.routingManager.route( + registerAdminRoutes(routingManager: RoutingManager) { + routingManager.route( { - path: '/templates', + path: '/email/templates', action: ConduitRouteActions.GET, description: `Returns queried templates and their total count.`, queryParams: { @@ -454,9 +445,9 @@ export class AdminHandlers { }), this.getTemplates.bind(this), ); - this.routingManager.route( + routingManager.route( { - path: '/templates', + path: '/email/templates', action: ConduitRouteActions.POST, description: `Creates a new email template.`, bodyParams: { @@ -473,9 +464,9 @@ export class AdminHandlers { }), this.createTemplate.bind(this), ); - this.routingManager.route( + routingManager.route( { - path: '/templates/:id', + path: '/email/templates/:id', action: ConduitRouteActions.PATCH, description: `Updates an email template.`, urlParams: { @@ -492,9 +483,9 @@ export class AdminHandlers { }), this.patchTemplate.bind(this), ); - this.routingManager.route( + routingManager.route( { - path: '/templates', + path: '/email/templates', action: ConduitRouteActions.DELETE, description: `Deletes queried email templates.`, queryParams: { @@ -506,9 +497,9 @@ export class AdminHandlers { }), this.deleteTemplates.bind(this), ); - this.routingManager.route( + routingManager.route( { - path: '/templates/:id', + path: '/email/templates/:id', action: ConduitRouteActions.DELETE, description: `Deletes an email template.`, urlParams: { @@ -520,9 +511,9 @@ export class AdminHandlers { }), this.deleteTemplate.bind(this), ); - this.routingManager.route( + routingManager.route( { - path: '/templates/upload', + path: '/email/templates/upload', action: ConduitRouteActions.POST, description: `Uploads a local email template to remote provider.`, bodyParams: { @@ -534,9 +525,9 @@ export class AdminHandlers { }), this.uploadTemplate.bind(this), ); - this.routingManager.route( + routingManager.route( { - path: '/externalTemplates', + path: '/email/externalTemplates', action: ConduitRouteActions.GET, description: `Returns external email templates and their total count.`, queryParams: { @@ -551,9 +542,9 @@ export class AdminHandlers { }), this.getExternalTemplates.bind(this), ); - this.routingManager.route( + routingManager.route( { - path: '/syncExternalTemplates', + path: '/email/syncExternalTemplates', action: ConduitRouteActions.UPDATE, description: `Synchronizes local email templates from remote provider.`, }, @@ -563,9 +554,9 @@ export class AdminHandlers { }), this.syncExternalTemplates.bind(this), ); - this.routingManager.route( + routingManager.route( { - path: '/send', + path: '/email/send', action: ConduitRouteActions.POST, description: `Sends an email.`, bodyParams: { @@ -582,9 +573,9 @@ export class AdminHandlers { }), this.sendEmail.bind(this), ); - this.routingManager.route( + routingManager.route( { - path: '/resend', + path: '/email/resend', action: ConduitRouteActions.POST, description: `Resends an email (only if stored in storage).`, bodyParams: { @@ -596,9 +587,9 @@ export class AdminHandlers { }), this.resendEmail.bind(this), ); - this.routingManager.route( + routingManager.route( { - path: '/status', + path: '/email/status', action: ConduitRouteActions.GET, description: `Returns the latest status of a sent email.`, queryParams: { @@ -610,9 +601,9 @@ export class AdminHandlers { }), this.getEmailStatus.bind(this), ); - this.routingManager.route( + routingManager.route( { - path: '/record', + path: '/email/record', action: ConduitRouteActions.GET, description: `Returns records of stored sent emails.`, queryParams: { @@ -635,6 +626,5 @@ export class AdminHandlers { }), this.getEmailRecords.bind(this), ); - this.routingManager.registerRoutes(); } } diff --git a/modules/email/src/email-provider/README.md b/modules/comms/src/modules/email/email-provider/README.md similarity index 100% rename from modules/email/src/email-provider/README.md rename to modules/comms/src/modules/email/email-provider/README.md diff --git a/modules/email/src/email-provider/index.ts b/modules/comms/src/modules/email/email-provider/index.ts similarity index 100% rename from modules/email/src/email-provider/index.ts rename to modules/comms/src/modules/email/email-provider/index.ts diff --git a/modules/email/src/email-provider/interfaces/CreateEmailTemplate.ts b/modules/comms/src/modules/email/email-provider/interfaces/CreateEmailTemplate.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/CreateEmailTemplate.ts rename to modules/comms/src/modules/email/email-provider/interfaces/CreateEmailTemplate.ts diff --git a/modules/email/src/email-provider/interfaces/DeleteEmailTemplate.ts b/modules/comms/src/modules/email/email-provider/interfaces/DeleteEmailTemplate.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/DeleteEmailTemplate.ts rename to modules/comms/src/modules/email/email-provider/interfaces/DeleteEmailTemplate.ts diff --git a/modules/email/src/email-provider/interfaces/EmailBuilder.ts b/modules/comms/src/modules/email/email-provider/interfaces/EmailBuilder.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/EmailBuilder.ts rename to modules/comms/src/modules/email/email-provider/interfaces/EmailBuilder.ts diff --git a/modules/email/src/email-provider/interfaces/EmailOptions.ts b/modules/comms/src/modules/email/email-provider/interfaces/EmailOptions.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/EmailOptions.ts rename to modules/comms/src/modules/email/email-provider/interfaces/EmailOptions.ts diff --git a/modules/email/src/email-provider/interfaces/EmailSendTransport.ts b/modules/comms/src/modules/email/email-provider/interfaces/EmailSendTransport.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/EmailSendTransport.ts rename to modules/comms/src/modules/email/email-provider/interfaces/EmailSendTransport.ts diff --git a/modules/email/src/email-provider/interfaces/Template.ts b/modules/comms/src/modules/email/email-provider/interfaces/Template.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/Template.ts rename to modules/comms/src/modules/email/email-provider/interfaces/Template.ts diff --git a/modules/email/src/email-provider/interfaces/TemplateDocument.ts b/modules/comms/src/modules/email/email-provider/interfaces/TemplateDocument.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/TemplateDocument.ts rename to modules/comms/src/modules/email/email-provider/interfaces/TemplateDocument.ts diff --git a/modules/email/src/email-provider/interfaces/TemplateOptions.ts b/modules/comms/src/modules/email/email-provider/interfaces/TemplateOptions.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/TemplateOptions.ts rename to modules/comms/src/modules/email/email-provider/interfaces/TemplateOptions.ts diff --git a/modules/email/src/email-provider/interfaces/UpdateEmailTemplate.ts b/modules/comms/src/modules/email/email-provider/interfaces/UpdateEmailTemplate.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/UpdateEmailTemplate.ts rename to modules/comms/src/modules/email/email-provider/interfaces/UpdateEmailTemplate.ts diff --git a/modules/email/src/email-provider/interfaces/Var.ts b/modules/comms/src/modules/email/email-provider/interfaces/Var.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/Var.ts rename to modules/comms/src/modules/email/email-provider/interfaces/Var.ts diff --git a/modules/email/src/email-provider/interfaces/mailgun/MailgunEmailOptions.ts b/modules/comms/src/modules/email/email-provider/interfaces/mailgun/MailgunEmailOptions.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/mailgun/MailgunEmailOptions.ts rename to modules/comms/src/modules/email/email-provider/interfaces/mailgun/MailgunEmailOptions.ts diff --git a/modules/email/src/email-provider/interfaces/mailgun/MailgunTemplate.ts b/modules/comms/src/modules/email/email-provider/interfaces/mailgun/MailgunTemplate.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/mailgun/MailgunTemplate.ts rename to modules/comms/src/modules/email/email-provider/interfaces/mailgun/MailgunTemplate.ts diff --git a/modules/email/src/email-provider/interfaces/mandrill/MandrillEmailOptions.ts b/modules/comms/src/modules/email/email-provider/interfaces/mandrill/MandrillEmailOptions.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/mandrill/MandrillEmailOptions.ts rename to modules/comms/src/modules/email/email-provider/interfaces/mandrill/MandrillEmailOptions.ts diff --git a/modules/email/src/email-provider/interfaces/mandrill/MandrillTemplate.ts b/modules/comms/src/modules/email/email-provider/interfaces/mandrill/MandrillTemplate.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/mandrill/MandrillTemplate.ts rename to modules/comms/src/modules/email/email-provider/interfaces/mandrill/MandrillTemplate.ts diff --git a/modules/email/src/email-provider/interfaces/sendgrid/SendgridEmailOptions.ts b/modules/comms/src/modules/email/email-provider/interfaces/sendgrid/SendgridEmailOptions.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/sendgrid/SendgridEmailOptions.ts rename to modules/comms/src/modules/email/email-provider/interfaces/sendgrid/SendgridEmailOptions.ts diff --git a/modules/email/src/email-provider/interfaces/sendgrid/SendgridTemplate.ts b/modules/comms/src/modules/email/email-provider/interfaces/sendgrid/SendgridTemplate.ts similarity index 100% rename from modules/email/src/email-provider/interfaces/sendgrid/SendgridTemplate.ts rename to modules/comms/src/modules/email/email-provider/interfaces/sendgrid/SendgridTemplate.ts diff --git a/modules/email/src/email-provider/models/EmailBuilderClass.ts b/modules/comms/src/modules/email/email-provider/models/EmailBuilderClass.ts similarity index 100% rename from modules/email/src/email-provider/models/EmailBuilderClass.ts rename to modules/comms/src/modules/email/email-provider/models/EmailBuilderClass.ts diff --git a/modules/email/src/email-provider/models/EmailProviderClass.ts b/modules/comms/src/modules/email/email-provider/models/EmailProviderClass.ts similarity index 100% rename from modules/email/src/email-provider/models/EmailProviderClass.ts rename to modules/comms/src/modules/email/email-provider/models/EmailProviderClass.ts diff --git a/modules/comms/src/modules/email/email-provider/transports/mailgun/MailgunProvider.ts b/modules/comms/src/modules/email/email-provider/transports/mailgun/MailgunProvider.ts new file mode 100644 index 000000000..645be6b67 --- /dev/null +++ b/modules/comms/src/modules/email/email-provider/transports/mailgun/MailgunProvider.ts @@ -0,0 +1,136 @@ +import { createTransport, SentMessageInfo } from 'nodemailer'; +import { Options } from 'nodemailer/lib/mailer'; +import { CreateEmailTemplate } from '../../interfaces/CreateEmailTemplate.js'; +import { UpdateEmailTemplate } from '../../interfaces/UpdateEmailTemplate.js'; +import { EmailBuilderClass } from '../../models/EmailBuilderClass.js'; +import { EmailProviderClass } from '../../models/EmailProviderClass.js'; +import { getHandleBarsValues } from '../../utils/index.js'; +import { initialize as initializeMailgun } from './mailgun.js'; +import { MailgunConfig } from './mailgun.config.js'; +import { MailgunMailBuilder } from './mailgunMailBuilder.js'; +import { DeleteEmailTemplate } from '../../interfaces/DeleteEmailTemplate.js'; +import { Indexable } from '@conduitplatform/grpc-sdk'; +import Mailgun, { Interfaces } from 'mailgun.js'; +import formData from 'form-data'; +import { Template } from '../../interfaces/Template.js'; +import { YesNo } from 'mailgun.js/Enums'; + +export class MailgunProvider extends EmailProviderClass { + protected _mailgunSdk: Interfaces.IMailgunClient; + private domain: string; + private apiKey: string; + + constructor(mailgunSettings: MailgunConfig) { + super(createTransport(initializeMailgun(mailgunSettings))); + this.domain = mailgunSettings.auth.domain; + this.apiKey = mailgunSettings.auth.api_key; + this._mailgunSdk = new Mailgun.default(formData).client({ + username: 'api', + key: this.apiKey, + url: mailgunSettings.host, + }); + } + + async listTemplates(): Promise { + const templates = await this._mailgunSdk.domains.domainTemplates.list(this.domain); + const retList: Promise