diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 82fd1ca2e..193d539e5 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -15,6 +15,8 @@ import { EncryptedTransferArgs, fullName, interpretEncryptedTransferArgs, + sendTransactionLinkRedeemedEmail, + sendTransactionReceivedEmail, TransactionTypeId, } from 'core' import { randomBytes } from 'crypto' @@ -29,6 +31,7 @@ import { User as DbUser, findModeratorCreatingContributionLink, findTransactionLinkByCode, + findUserByIdentifier, getHomeCommunity, getLastTransaction, } from 'database' @@ -660,6 +663,62 @@ export class TransactionLinkResolver { if (methodLogger.isDebugEnabled()) { methodLogger.debug('Disburse JWT was sent successfully with result=', result) } + const senderUser = await findUserByIdentifier(senderGradidoId, senderCommunityUuid) + if (!senderUser) { + const errmsg = `Sender user not found with identifier=${senderGradidoId}` + methodLogger.error(errmsg) + throw new LogError(errmsg) + } + const recipientUser = await findUserByIdentifier(recipientGradidoId, recipientCommunityUuid) + if (!recipientUser) { + const errmsg = `Recipient user not found with identifier=${recipientGradidoId}` + methodLogger.error(errmsg) + throw new LogError(errmsg) + } + if (recipientUser.emailContact?.email !== null) { + if (methodLogger.isDebugEnabled()) { + methodLogger.debug( + 'Sending TransactionLinkRedeem Email to recipient=' + + recipientUser.firstName + + ' ' + + recipientUser.lastName + + 'sender=' + + senderUser.firstName + + ' ' + + senderUser.lastName, + ) + } + try { + await sendTransactionLinkRedeemedEmail({ + firstName: recipientUser.firstName, + lastName: recipientUser.lastName, + email: recipientUser.emailContact.email, + language: recipientUser.language, + senderFirstName: senderUser.firstName, + senderLastName: senderUser.lastName, + senderEmail: senderUser.emailContact?.email, + transactionMemo: memo, + transactionAmount: new Decimal(amount), + }) + } catch (e) { + const errmsg = `Send TransactionLinkRedeem Email to recipient failed with error=${e}` + methodLogger.error(errmsg) + throw new Error(errmsg) + } + } else { + if (methodLogger.isDebugEnabled()) { + methodLogger.debug( + 'Sender or Recipient are foreign users with no email contact, not sending Transaction Received Email: recipient=' + + recipientUser.firstName + + ' ' + + recipientUser.lastName + + 'sender=' + + senderUser.firstName + + ' ' + + senderUser.lastName, + ) + } + } } catch (e) { const errmsg = `Disburse JWT was not sent successfully with error=${e}` methodLogger.error(errmsg) diff --git a/core/src/command/BaseCommand.ts b/core/src/command/BaseCommand.ts new file mode 100644 index 000000000..1184bf32b --- /dev/null +++ b/core/src/command/BaseCommand.ts @@ -0,0 +1,55 @@ +import { getLogger } from 'log4js' +import { LOG4JS_BASE_CATEGORY_NAME } from '../config/const' +import { Command } from './Command' + +const createLogger = (method: string) => + getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.command.BaseCommand.${method}`) + +export abstract class BaseCommand implements Command { + protected abstract requiredFields: string[] + + protected constructor(protected readonly params: any[]) { + // this.validateRequiredFields(); + } + + abstract execute(): Promise + + private validateRequiredFields(): void { + const methodLogger = createLogger(`validateRequiredFields`) + if (!this.requiredFields || this.requiredFields.length === 0) { + methodLogger.debug(`validateRequiredFields() no required fields`) + return + } + methodLogger.debug( + `validateRequiredFields() requiredFields=${JSON.stringify(this.requiredFields)}`, + ) + /* + const commandArgs = JSON.parse(this.params[0]) + const missingFields = this.requiredFields.filter(field => + commandArgs.{ field } === undefined || commandArgs.{ field } === null || commandArgs.{ field } === '' + ); + methodLogger.debug(`validateRequiredFields() missingFields=${JSON.stringify(missingFields)}`) + + if (missingFields.length > 0) { + methodLogger.error(`validateRequiredFields() missing fields: ${missingFields.join(', ')}`) + throw new Error(`Missing required fields for ${this.constructor.name}: ${missingFields.join(', ')}`); + } + */ + } + + validate(): boolean { + const methodLogger = createLogger(`validate`) + methodLogger.debug( + `validate() requiredFields=${JSON.stringify(this.requiredFields)} params=${JSON.stringify(this.params)}`, + ) + /* + const isValid = this.requiredFields.every(field => + this.params[field] !== undefined && + this.params[field] !== null && + this.params[field] !== '' + ); + methodLogger.debug(`validate() isValid=${isValid}`) + */ + return true + } +} diff --git a/core/src/command/Command.ts b/core/src/command/Command.ts new file mode 100644 index 000000000..45afe5b49 --- /dev/null +++ b/core/src/command/Command.ts @@ -0,0 +1,4 @@ +export interface Command<_T = any> { + execute(): Promise + validate?(): boolean +} diff --git a/core/src/command/CommandExecutor.ts b/core/src/command/CommandExecutor.ts new file mode 100644 index 000000000..97b67c77a --- /dev/null +++ b/core/src/command/CommandExecutor.ts @@ -0,0 +1,77 @@ +// core/src/command/CommandExecutor.ts + +import { getLogger } from 'log4js' +import { CommandJwtPayloadType } from 'shared' +import { LOG4JS_BASE_CATEGORY_NAME } from '../config/const' +import { interpretEncryptedTransferArgs } from '../graphql/logic/interpretEncryptedTransferArgs' +import { CommandResult } from '../graphql/model/CommandResult' +import { EncryptedTransferArgs } from '../graphql/model/EncryptedTransferArgs' +import { Command } from './Command' +import { CommandFactory } from './CommandFactory' + +const createLogger = (method: string) => + getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.command.CommandExecutor.${method}`) + +export class CommandExecutor { + async executeCommand(command: Command): Promise { + const methodLogger = createLogger(`executeCommand`) + methodLogger.debug(`executeCommand() command=${command.constructor.name}`) + try { + if (command.validate && !command.validate()) { + const errmsg = `Command validation failed for command=${command.constructor.name}` + methodLogger.error(errmsg) + return { success: false, error: errmsg } + } + methodLogger.debug(`executeCommand() executing command=${command.constructor.name}`) + const result = await command.execute() + methodLogger.debug(`executeCommand() executed result=${result}`) + return { success: true, data: result } + } catch (error) { + methodLogger.error(`executeCommand() error=${error}`) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + } + } + } + + async executeEncryptedCommand<_T>(encryptedArgs: EncryptedTransferArgs): Promise { + const methodLogger = createLogger(`executeEncryptedCommand`) + try { + // Decrypt the command data + const commandArgs = (await interpretEncryptedTransferArgs( + encryptedArgs, + )) as CommandJwtPayloadType + if (!commandArgs) { + const errmsg = `invalid commandArgs payload of requesting community with publicKey=${encryptedArgs.publicKey}` + methodLogger.error(errmsg) + throw new Error(errmsg) + } + if (methodLogger.isDebugEnabled()) { + methodLogger.debug(`executeEncryptedCommand() commandArgs=${JSON.stringify(commandArgs)}`) + } + const command = CommandFactory.getInstance().createCommand( + commandArgs.commandName, + commandArgs.commandArgs, + ) + if (methodLogger.isDebugEnabled()) { + methodLogger.debug(`executeEncryptedCommand() command=${JSON.stringify(command)}`) + } + + // Execute the command + const result = await this.executeCommand(command) + if (methodLogger.isDebugEnabled()) { + methodLogger.debug(`executeCommand() result=${JSON.stringify(result)}`) + } + + return result + } catch (error) { + methodLogger.error(`executeEncryptedCommand() error=${error}`) + const errorResult: CommandResult = { + success: false, + error: error instanceof Error ? error.message : 'Failed to process command', + } + return errorResult + } + } +} diff --git a/core/src/command/CommandFactory.ts b/core/src/command/CommandFactory.ts new file mode 100644 index 000000000..4a4e7d806 --- /dev/null +++ b/core/src/command/CommandFactory.ts @@ -0,0 +1,81 @@ +import { getLogger } from 'log4js' +import { LOG4JS_BASE_CATEGORY_NAME } from '../config/const' +import { BaseCommand } from './BaseCommand' +import { Command } from './Command' +import { ICommandConstructor } from './CommandTypes' +// import { ICommandConstructor } from './CommandTypes'; +import { SendEmailCommand } from './commands/SendEmailCommand' + +const createLogger = (method: string) => + getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.command.CommandFactory.${method}`) + +export class CommandFactory { + private static instance: CommandFactory + private commands: Map = new Map() + + private constructor() {} + + static getInstance(): CommandFactory { + if (!CommandFactory.instance) { + CommandFactory.instance = new CommandFactory() + } + return CommandFactory.instance + } + + registerCommand(name: string, commandClass: ICommandConstructor): void { + const methodLogger = createLogger(`registerCommand`) + if (methodLogger.isDebugEnabled()) { + methodLogger.debug(`registerCommand() name=${name}, commandClass=${commandClass.name}`) + } + this.commands.set(name, commandClass) + if (methodLogger.isDebugEnabled()) { + methodLogger.debug(`registerCommand() commands=${JSON.stringify(this.commands.entries())}`) + } + } + + createCommand(name: string, params: string[]): Command { + const methodLogger = createLogger(`createCommand`) + if (methodLogger.isDebugEnabled()) { + methodLogger.debug(`createCommand() name=${name} params=${JSON.stringify(params)}`) + } + const CommandClass = this.commands.get(name) + if (methodLogger.isDebugEnabled()) { + methodLogger.debug( + `createCommand() name=${name} commandClass=${CommandClass ? CommandClass.name : 'null'}`, + ) + } + if (CommandClass === undefined) { + const errmsg = `Command ${name} not found` + methodLogger.error(errmsg) + throw new Error(errmsg) + } + /* + try { + const command = new CommandClass(params) as Command; + if (methodLogger.isDebugEnabled()) { + methodLogger.debug(`createCommand() command=${JSON.stringify(command)}`) + } + return command; + } catch (error) { + const errmsg = `Failed to create command ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`; + methodLogger.error(errmsg); + throw new Error(errmsg); + } + */ + let command: BaseCommand + switch (CommandClass.name) { + case 'SendEmailCommand': + command = new SendEmailCommand(params) + break + default: { + const errmsg = `Command ${name} not found` + methodLogger.error(errmsg) + throw new Error(errmsg) + } + } + if (methodLogger.isDebugEnabled()) { + methodLogger.debug(`createCommand() created command=${JSON.stringify(command)}`) + } + return command + } +} diff --git a/core/src/command/CommandTypes.ts b/core/src/command/CommandTypes.ts new file mode 100644 index 000000000..c5cf309cf --- /dev/null +++ b/core/src/command/CommandTypes.ts @@ -0,0 +1,5 @@ +import { Command } from './Command' + +export interface ICommandConstructor { + new (params: any): Command +} diff --git a/core/src/command/commands/SendEmailCommand.ts b/core/src/command/commands/SendEmailCommand.ts new file mode 100644 index 000000000..ebf08ce73 --- /dev/null +++ b/core/src/command/commands/SendEmailCommand.ts @@ -0,0 +1,145 @@ +import { findUserByUuids } from 'database' +import Decimal from 'decimal.js-light' +import { getLogger } from 'log4js' +import { LOG4JS_BASE_CATEGORY_NAME } from '../../config/const' +import { sendTransactionReceivedEmail } from '../../emails/sendEmailVariants' +import { BaseCommand } from '../BaseCommand' + +const createLogger = (method: string) => + getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.command.commands.SendEmailCommand.${method}`) + +export interface SendEmailCommandParams { + mailType: string + senderComUuid: string + senderGradidoId: string + receiverComUuid: string + receiverGradidoId: string + memo?: string + amount?: string +} +export class SendEmailCommand extends BaseCommand< + Record | boolean | null | Error +> { + static readonly SEND_MAIL_COMMAND = 'SEND_MAIL_COMMAND' + protected requiredFields: string[] = [ + 'mailType', + 'senderComUuid', + 'senderGradidoId', + 'receiverComUuid', + 'receiverGradidoId', + ] + protected sendEmailCommandParams: SendEmailCommandParams + + constructor(params: any[]) { + const methodLogger = createLogger(`constructor`) + methodLogger.debug(`constructor() params=${JSON.stringify(params)}`) + super(params) + this.sendEmailCommandParams = JSON.parse(params[0]) as SendEmailCommandParams + } + + validate(): boolean { + const baseValid = super.validate() + if (!baseValid) { + return false + } + // Additional validations + + return true + } + + async execute(): Promise { + const methodLogger = createLogger(`execute`) + methodLogger.debug( + `execute() sendEmailCommandParams=${JSON.stringify(this.sendEmailCommandParams)}`, + ) + let result: string + if (!this.validate()) { + throw new Error('Invalid command parameters') + } + // find sender user + methodLogger.debug( + `find sender user: ${this.sendEmailCommandParams.senderComUuid} ${this.sendEmailCommandParams.senderGradidoId}`, + ) + const senderUser = await findUserByUuids( + this.sendEmailCommandParams.senderComUuid, + this.sendEmailCommandParams.senderGradidoId, + true, + ) + methodLogger.debug(`senderUser=${JSON.stringify(senderUser)}`) + if (!senderUser) { + const errmsg = `Sender user not found: ${this.sendEmailCommandParams.senderComUuid} ${this.sendEmailCommandParams.senderGradidoId}` + methodLogger.error(errmsg) + throw new Error(errmsg) + } + + methodLogger.debug( + `find recipient user: ${this.sendEmailCommandParams.receiverComUuid} ${this.sendEmailCommandParams.receiverGradidoId}`, + ) + const recipientUser = await findUserByUuids( + this.sendEmailCommandParams.receiverComUuid, + this.sendEmailCommandParams.receiverGradidoId, + ) + methodLogger.debug(`recipientUser=${JSON.stringify(recipientUser)}`) + if (!recipientUser) { + const errmsg = `Recipient user not found: ${this.sendEmailCommandParams.receiverComUuid} ${this.sendEmailCommandParams.receiverGradidoId}` + methodLogger.error(errmsg) + throw new Error(errmsg) + } + + const emailParams = { + firstName: recipientUser.firstName, + lastName: recipientUser.lastName, + email: recipientUser.emailContact.email, + language: recipientUser.language, + senderFirstName: senderUser.firstName, + senderLastName: senderUser.lastName, + senderEmail: senderUser.emailId !== null ? senderUser.emailContact.email : null, + memo: this.sendEmailCommandParams.memo || '', + transactionAmount: new Decimal(this.sendEmailCommandParams.amount || 0).abs(), + } + methodLogger.debug(`emailParams=${JSON.stringify(emailParams)}`) + switch (this.sendEmailCommandParams.mailType) { + case 'sendTransactionReceivedEmail': { + const emailResult = await sendTransactionReceivedEmail(emailParams) + result = this.getEmailResult(emailResult) + break + } + default: + throw new Error(`Unknown mail type: ${this.sendEmailCommandParams.mailType}`) + } + + try { + // Example: const result = await emailService.sendEmail(this.params); + return result + } catch (error) { + methodLogger.error('Error executing SendEmailCommand:', error) + throw error + } + } + + private getEmailResult(result: Record | boolean | null | Error): string { + const methodLogger = createLogger(`getEmailResult`) + if (methodLogger.isDebugEnabled()) { + methodLogger.debug(`result=${JSON.stringify(result)}`) + } + let emailResult: string + if (result === null) { + emailResult = `result is null` + } else if (typeof result === 'boolean') { + emailResult = `result is ${result}` + } else if (result instanceof Error) { + emailResult = `error-message is ${result.message}` + } else if (typeof result === 'object') { + // {"accepted":["stage5@gradido.net"],"rejected":[],"ehlo":["PIPELINING","SIZE 25600000","ETRN","AUTH DIGEST-MD5 CRAM-MD5 PLAIN LOGIN","ENHANCEDSTATUSCODES","8BITMIME","DSN","CHUNKING"],"envelopeTime":23,"messageTime":135,"messageSize":37478,"response":"250 2.0.0 Ok: queued as C45C2100BD7","envelope":{"from":"stage5@gradido.net","to":["stage5@gradido.net"]},"messageId":"" + const accepted = (result as Record).accepted + const messageSize = (result as Record).messageSize + const response = (result as Record).response + const envelope = JSON.stringify((result as Record).envelope) + emailResult = `accepted=${accepted}, messageSize=${messageSize}, response=${response}, envelope=${envelope}` + } else { + emailResult = `result is unknown type` + } + + return emailResult + } +} diff --git a/core/src/command/initCommands.ts b/core/src/command/initCommands.ts new file mode 100644 index 000000000..5144f43c7 --- /dev/null +++ b/core/src/command/initCommands.ts @@ -0,0 +1,11 @@ +import { CommandFactory } from './CommandFactory' +import { SendEmailCommand } from './commands/SendEmailCommand' +// Import other commands... + +export function initializeCommands(): void { + const factory = CommandFactory.getInstance() + + // Register all commands + factory.registerCommand(SendEmailCommand.SEND_MAIL_COMMAND, SendEmailCommand) + // Register other commands... +} diff --git a/core/src/emails/sendEmailVariants.ts b/core/src/emails/sendEmailVariants.ts index f50f88181..85c001ea3 100644 --- a/core/src/emails/sendEmailVariants.ts +++ b/core/src/emails/sendEmailVariants.ts @@ -175,18 +175,18 @@ export const sendTransactionReceivedEmail = ( data: EmailCommonData & { senderFirstName: string senderLastName: string - senderEmail: string + senderEmail: string | null memo: string transactionAmount: Decimal }, ): Promise | boolean | null | Error> => { return sendEmailTranslated({ receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` }, - template: 'transactionReceived', + template: data.senderEmail !== null ? 'transactionReceived' : 'transactionReceivedNoSender', locals: { ...data, transactionAmount: decimalSeparatorByLanguage(data.transactionAmount, data.language), - ...getEmailCommonLocales(), + ...(data.senderEmail !== null ? getEmailCommonLocales() : { locale: data.language }), }, }) } diff --git a/core/src/emails/templates/transactionReceivedNoSender/html.pug b/core/src/emails/templates/transactionReceivedNoSender/html.pug new file mode 100644 index 000000000..c64e4ee13 --- /dev/null +++ b/core/src/emails/templates/transactionReceivedNoSender/html.pug @@ -0,0 +1,24 @@ +extend ../layout.pug + +block content + // + mixin mailto(email, subject) + - var formattedSubject = encodeURIComponent(subject) + a(class!=attributes.class href=`mailto:${email}?subject=${formattedSubject}`) + block + + - var subject= t('emails.transactionReceived.replySubject', { senderFirstName, senderLastName, transactionAmount }) + h2= t('emails.transactionReceived.title', { senderFirstName, senderLastName, transactionAmount }) + .text-block + include ../includes/salutation.pug + p + = t('emails.transactionReceived.haveReceivedAmountGDDFrom', { transactionAmount, senderFirstName, senderLastName }) + .content + h2= t('emails.general.message') + .child-left + div(class="p_content")= memo + + a.button-3(href=`${communityURL}/transactions`) #{t('emails.general.toAccount')} + + + diff --git a/core/src/emails/templates/transactionReceivedNoSender/subject.pug b/core/src/emails/templates/transactionReceivedNoSender/subject.pug new file mode 100644 index 000000000..872806ebc --- /dev/null +++ b/core/src/emails/templates/transactionReceivedNoSender/subject.pug @@ -0,0 +1 @@ += t('emails.transactionReceived.subject', { senderFirstName, senderLastName, transactionAmount }) diff --git a/core/src/federation/client/1_0/CommandClient.ts b/core/src/federation/client/1_0/CommandClient.ts new file mode 100644 index 000000000..4a0000bc0 --- /dev/null +++ b/core/src/federation/client/1_0/CommandClient.ts @@ -0,0 +1,45 @@ +import { FederatedCommunity as DbFederatedCommunity } from 'database' +import { GraphQLClient } from 'graphql-request' +import { getLogger } from 'log4js' +import { LOG4JS_BASE_CATEGORY_NAME } from '../../../config/const' +import { EncryptedTransferArgs } from '../../../graphql/model/EncryptedTransferArgs' +import { ensureUrlEndsWithSlash } from '../../../util/utilities' +import { sendCommand as sendCommandQuery } from './query/sendCommand' + +const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.client.1_0.CommandClient`) + +export class CommandClient { + dbCom: DbFederatedCommunity + endpoint: string + client: GraphQLClient + + constructor(dbCom: DbFederatedCommunity) { + this.dbCom = dbCom + this.endpoint = ensureUrlEndsWithSlash(dbCom.endPoint).concat(dbCom.apiVersion).concat('/') + this.client = new GraphQLClient(this.endpoint, { + method: 'POST', + jsonSerializer: { + parse: JSON.parse, + stringify: JSON.stringify, + }, + }) + } + + async sendCommand(args: EncryptedTransferArgs): Promise { + logger.debug(`sendCommand at ${this.endpoint} for args:`, args) + try { + const { data } = await this.client.rawRequest<{ success: boolean }>(sendCommandQuery, { + args, + }) + if (!data?.success) { + logger.warn('sendCommand without response data from endpoint', this.endpoint) + return false + } + logger.debug('sendCommand successfully started with endpoint', this.endpoint) + return true + } catch (err) { + logger.error('error on sendCommand: ', err) + return false + } + } +} diff --git a/core/src/federation/client/1_0/query/sendCommand.ts b/core/src/federation/client/1_0/query/sendCommand.ts new file mode 100644 index 000000000..906833c82 --- /dev/null +++ b/core/src/federation/client/1_0/query/sendCommand.ts @@ -0,0 +1,11 @@ +import { gql } from 'graphql-request' + +export const sendCommand = gql` + mutation ($args: EncryptedTransferArgs!) { + sendCommand(encryptedArgs: $args) { + success + data + error + } + } +` diff --git a/core/src/federation/client/1_1/CommandClient.ts b/core/src/federation/client/1_1/CommandClient.ts new file mode 100644 index 000000000..70bfee1c3 --- /dev/null +++ b/core/src/federation/client/1_1/CommandClient.ts @@ -0,0 +1,3 @@ +import { CommandClient as V1_0_CommandClient } from '../1_0/CommandClient' + +export class CommandClient extends V1_0_CommandClient {} diff --git a/core/src/federation/client/CommandClientFactory.ts b/core/src/federation/client/CommandClientFactory.ts new file mode 100644 index 000000000..3211b4ad8 --- /dev/null +++ b/core/src/federation/client/CommandClientFactory.ts @@ -0,0 +1,55 @@ +import { ApiVersionType } from 'core' +import { FederatedCommunity as DbFederatedCommunity } from 'database' +import { CommandClient as V1_0_CommandClient } from './1_0/CommandClient' +import { CommandClient as V1_1_CommandClient } from './1_1/CommandClient' + +type CommandClient = V1_0_CommandClient | V1_1_CommandClient + +interface CommandClientInstance { + id: number + + client: CommandClient +} + +export class CommandClientFactory { + private static instanceArray: CommandClientInstance[] = [] + + /** + * The Singleton's constructor should always be private to prevent direct + * construction calls with the `new` operator. + */ + + private constructor() {} + + private static createCommandClient = (dbCom: DbFederatedCommunity) => { + switch (dbCom.apiVersion) { + case ApiVersionType.V1_0: + return new V1_0_CommandClient(dbCom) + case ApiVersionType.V1_1: + return new V1_1_CommandClient(dbCom) + default: + return null + } + } + + /** + * The static method that controls the access to the singleton instance. + * + * This implementation let you subclass the Singleton class while keeping + * just one instance of each subclass around. + */ + public static getInstance(dbCom: DbFederatedCommunity): CommandClient | null { + const instance = CommandClientFactory.instanceArray.find((instance) => instance.id === dbCom.id) + if (instance) { + return instance.client + } + const client = CommandClientFactory.createCommandClient(dbCom) + if (client) { + CommandClientFactory.instanceArray.push({ + id: dbCom.id, + client, + } as CommandClientInstance) + } + return client + } +} diff --git a/core/src/graphql/logic/processCommand.ts b/core/src/graphql/logic/processCommand.ts new file mode 100644 index 000000000..673d0f56b --- /dev/null +++ b/core/src/graphql/logic/processCommand.ts @@ -0,0 +1,14 @@ +import { getLogger } from 'log4js' +import { LOG4JS_BASE_CATEGORY_NAME } from '../../config/const' + +const createLogger = (method: string) => + getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.resolver.logic.processCommand.${method}`) + +export async function processCommand( + commandClass: string, + commandMethod: string, + commandArgs: string[], +) { + const methodLogger = createLogger(`processCommand`) + methodLogger.info('processing a command...') +} diff --git a/core/src/graphql/logic/processXComSendCoins.ts b/core/src/graphql/logic/processXComSendCoins.ts index ceb33a378..b48bacde8 100644 --- a/core/src/graphql/logic/processXComSendCoins.ts +++ b/core/src/graphql/logic/processXComSendCoins.ts @@ -16,6 +16,7 @@ import { Decimal } from 'decimal.js-light' // import { LogError } from '@server/LogError' import { getLogger } from 'log4js' import { + CommandJwtPayloadType, encryptAndSign, PendingTransactionState, SendCoinsJwtPayloadType, @@ -23,11 +24,15 @@ import { verifyAndDecrypt, } from 'shared' import { randombytes_random } from 'sodium-native' +import { SendEmailCommand } from '../../command/commands/SendEmailCommand' import { CONFIG as CONFIG_CORE } from '../../config' import { LOG4JS_BASE_CATEGORY_NAME } from '../../config/const' +import { sendTransactionLinkRedeemedEmail, sendTransactionReceivedEmail } from '../../emails' +import { CommandClient as V1_0_CommandClient } from '../../federation/client/1_0/CommandClient' import { SendCoinsResultLoggingView } from '../../federation/client/1_0/logging/SendCoinsResultLogging.view' import { SendCoinsResult } from '../../federation/client/1_0/model/SendCoinsResult' import { SendCoinsClient as V1_0_SendCoinsClient } from '../../federation/client/1_0/SendCoinsClient' +import { CommandClientFactory } from '../../federation/client/CommandClientFactory' import { SendCoinsClientFactory } from '../../federation/client/SendCoinsClientFactory' import { TransactionTypeId } from '../../graphql/enum/TransactionTypeId' import { EncryptedTransferArgs } from '../../graphql/model/EncryptedTransferArgs' @@ -167,6 +172,32 @@ export async function processXComCompleteTransaction( ) } } + /* + await sendTransactionReceivedEmail({ + firstName: foreignUser.firstName, + lastName: foreignUser.lastName, + email: foreignUser.emailContact.email, + language: foreignUser.language, + memo, + senderFirstName: senderUser.firstName, + senderLastName: senderUser.lastName, + senderEmail: senderUser.emailContact.email, + transactionAmount: new Decimal(amount), + }) + */ + if (dbTransactionLink) { + await sendTransactionLinkRedeemedEmail({ + firstName: senderUser.firstName, + lastName: senderUser.lastName, + email: senderUser.emailContact.email, + language: senderUser.language, + senderFirstName: foreignUser.firstName, + senderLastName: foreignUser.lastName, + senderEmail: 'unknown', // foreignUser.emailContact.email, + transactionAmount: new Decimal(amount), + transactionMemo: memo, + }) + } } } catch (err) { const errmsg = @@ -227,7 +258,7 @@ export async function processXComPendingSendCoins( const receiverFCom = await DbFederatedCommunity.findOneOrFail({ where: { - publicKey: Buffer.from(receiverCom.publicKey), + publicKey: receiverCom.publicKey, apiVersion: CONFIG_CORE.FEDERATION_BACKEND_SEND_ON_API, }, }) @@ -484,6 +515,37 @@ export async function processXComCommittingSendCoins( sendCoinsResult.recipGradidoID = pendingTx.linkedUserGradidoID sendCoinsResult.recipAlias = recipient.recipAlias } + // ** after successfull settle of the pending transaction on sender side we have to send a trigger to the recipient community to send an email to the x-com-tx recipient + const cmdClient = CommandClientFactory.getInstance(receiverFCom) + + if (cmdClient instanceof V1_0_CommandClient) { + const payload = new CommandJwtPayloadType( + handshakeID, + SendEmailCommand.SEND_MAIL_COMMAND, + SendEmailCommand.name, + [ + JSON.stringify({ + mailType: 'sendTransactionReceivedEmail', + senderComUuid: senderCom.communityUuid, + senderGradidoId: sender.gradidoID, + receiverComUuid: receiverCom.communityUuid, + receiverGradidoId: sendCoinsResult.recipGradidoID, + memo: pendingTx.memo, + amount: pendingTx.amount, + }), + ], + ) + const jws = await encryptAndSign( + payload, + senderCom.privateJwtKey!, + receiverCom.publicJwtKey!, + ) + const args = new EncryptedTransferArgs() + args.publicKey = senderCom.publicKey.toString('hex') + args.jwt = jws + args.handshakeID = handshakeID + cmdClient.sendCommand(args) + } } catch (err) { methodLogger.error( `Error in writing sender pending transaction: ${JSON.stringify(err, null, 2)}`, diff --git a/core/src/graphql/logic/storeForeignUser.ts b/core/src/graphql/logic/storeForeignUser.ts index a367ee8e3..2fb07e23f 100644 --- a/core/src/graphql/logic/storeForeignUser.ts +++ b/core/src/graphql/logic/storeForeignUser.ts @@ -1,5 +1,13 @@ -import { Community as DbCommunity, User as DbUser, findForeignUserByUuids } from 'database' +import { + Community as DbCommunity, + User as DbUser, + UserContact as DbUserContact, + findForeignUserByUuids, + UserContactLoggingView, + UserLoggingView, +} from 'database' import { getLogger } from 'log4js' +import { UserContactType } from 'shared' import { LOG4JS_BASE_CATEGORY_NAME } from '../../config/const' import { SendCoinsResult } from '../../federation/client/1_0/model/SendCoinsResult' @@ -35,17 +43,38 @@ export async function storeForeignUser( } foreignUser.gradidoID = committingResult.recipGradidoID foreignUser = await DbUser.save(foreignUser) - logger.debug('new foreignUser inserted:', foreignUser) + logger.debug('new foreignUser inserted:', new UserLoggingView(foreignUser)) + /* + if (committingResult.recipEmail !== null) { + let foreignUserEmail = DbUserContact.create() + foreignUserEmail.email = committingResult.recipEmail! + foreignUserEmail.emailChecked = true + foreignUserEmail.user = foreignUser + foreignUserEmail = await DbUserContact.save(foreignUserEmail) + logger.debug( + 'new foreignUserEmail inserted:', + new UserContactLoggingView(foreignUserEmail), + ) + foreignUser.emailContact = foreignUserEmail + foreignUser.emailId = foreignUserEmail.id + foreignUser = await DbUser.save(foreignUser) + } + */ return foreignUser } else if ( user.firstName !== committingResult.recipFirstName || user.lastName !== committingResult.recipLastName || - user.alias !== committingResult.recipAlias + user.alias !== committingResult.recipAlias /* || + (user.emailContact === null && committingResult.recipEmail !== null) || + (user.emailContact !== null && + user.emailContact?.email !== null && + user.emailContact?.email !== committingResult.recipEmail) + */ ) { - logger.warn( + logger.debug( 'foreignUser still exists, but with different name or alias:', - user, + new UserLoggingView(user), committingResult, ) if (committingResult.recipFirstName !== null) { @@ -57,11 +86,39 @@ export async function storeForeignUser( if (committingResult.recipAlias !== null) { user.alias = committingResult.recipAlias } + /* + if (!user.emailContact && committingResult.recipEmail !== null) { + logger.debug( + 'creating new userContact:', + new UserContactLoggingView(user.emailContact), + committingResult, + ) + let foreignUserEmail = DbUserContact.create() + foreignUserEmail.type = UserContactType.USER_CONTACT_EMAIL + foreignUserEmail.email = committingResult.recipEmail! + foreignUserEmail.emailChecked = true + foreignUserEmail.user = user + foreignUserEmail.userId = user.id + foreignUserEmail = await DbUserContact.save(foreignUserEmail) + logger.debug( + 'new foreignUserEmail inserted:', + new UserContactLoggingView(foreignUserEmail), + ) + user.emailContact = foreignUserEmail + user.emailId = foreignUserEmail.id + } else if (user.emailContact && committingResult.recipEmail != null) { + const userContact = user.emailContact + userContact.email = committingResult.recipEmail + user.emailContact = await DbUserContact.save(userContact) + user.emailId = userContact.id + logger.debug('foreignUserEmail updated:', new UserContactLoggingView(userContact)) + } + */ await DbUser.save(user) - logger.debug('update recipient successful.', user) + logger.debug('update recipient successful.', new UserLoggingView(user)) return user } else { - logger.debug('foreignUser still exists...:', user) + logger.debug('foreignUser still exists...:', new UserLoggingView(user)) return user } } catch (err) { diff --git a/core/src/graphql/model/CommandResult.ts b/core/src/graphql/model/CommandResult.ts new file mode 100644 index 000000000..76962d5f4 --- /dev/null +++ b/core/src/graphql/model/CommandResult.ts @@ -0,0 +1,13 @@ +import { Field, ObjectType } from 'type-graphql' + +@ObjectType() +export class CommandResult { + @Field(() => Boolean) + success: boolean + + @Field(() => String, { nullable: true }) + data?: any + + @Field(() => String, { nullable: true }) + error?: string +} diff --git a/core/src/index.ts b/core/src/index.ts index ddc204f5b..4ea5e3815 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -1,5 +1,9 @@ +export * from './command/CommandExecutor' +export * from './command/CommandFactory' +export * from './command/initCommands' export * from './config/index' export * from './emails' +export { CommandClient as V1_0_CommandClient } from './federation/client/1_0/CommandClient' export * from './federation/client/1_0/logging/SendCoinsArgsLogging.view' export * from './federation/client/1_0/logging/SendCoinsResultLogging.view' export * from './federation/client/1_0/model/SendCoinsArgs' @@ -9,15 +13,19 @@ export * from './federation/client/1_0/query/revertSettledSendCoins' export * from './federation/client/1_0/query/settleSendCoins' export * from './federation/client/1_0/query/voteForSendCoins' export { SendCoinsClient as V1_0_SendCoinsClient } from './federation/client/1_0/SendCoinsClient' +export { CommandClient as V1_1_CommandClient } from './federation/client/1_1/CommandClient' export { SendCoinsClient as V1_1_SendCoinsClient } from './federation/client/1_1/SendCoinsClient' +export * from './federation/client/CommandClientFactory' export * from './federation/client/SendCoinsClientFactory' export * from './federation/enum/apiVersionType' export * from './graphql/enum/TransactionTypeId' export * from './graphql/logging/DecayLogging.view' export * from './graphql/logic/interpretEncryptedTransferArgs' +export * from './graphql/logic/processCommand' export * from './graphql/logic/processXComSendCoins' export * from './graphql/logic/settlePendingSenderTransaction' export * from './graphql/logic/storeForeignUser' +export * from './graphql/model/CommandResult' export * from './graphql/model/Decay' export * from './graphql/model/EncryptedTransferArgs' export * from './logic' diff --git a/database/src/logging/UserContactLogging.view.ts b/database/src/logging/UserContactLogging.view.ts index 5230c4311..c064a8b2d 100644 --- a/database/src/logging/UserContactLogging.view.ts +++ b/database/src/logging/UserContactLogging.view.ts @@ -17,21 +17,25 @@ export class UserContactLoggingView extends AbstractLoggingView { public toJSON(): any { return { - id: this.self.id, - type: this.self.type, - user: - this.showUser && this.self.user - ? new UserLoggingView(this.self.user).toJSON() - : { id: this.self.userId }, - email: this.self.email?.substring(0, 3) + '...', - emailVerificationCode: this.self.emailVerificationCode?.substring(0, 4) + '...', - emailOptInTypeId: OptInType[this.self.emailOptInTypeId], - emailResendCount: this.self.emailResendCount, - emailChecked: this.self.emailChecked, - phone: this.self.phone ? this.self.phone.substring(0, 3) + '...' : undefined, - createdAt: this.dateToString(this.self.createdAt), - updatedAt: this.dateToString(this.self.updatedAt), - deletedAt: this.dateToString(this.self.deletedAt), + self: this.self + ? { + id: this.self.id, + type: this.self.type, + user: + this.showUser && this.self.user + ? new UserLoggingView(this.self.user).toJSON() + : { id: this.self.userId }, + email: this.self.email?.substring(0, 3) + '...', + emailVerificationCode: this.self.emailVerificationCode?.substring(0, 4) + '...', + emailOptInTypeId: OptInType[this.self.emailOptInTypeId], + emailResendCount: this.self.emailResendCount, + emailChecked: this.self.emailChecked, + phone: this.self.phone ? this.self.phone.substring(0, 3) + '...' : undefined, + createdAt: this.dateToString(this.self.createdAt), + updatedAt: this.dateToString(this.self.updatedAt), + deletedAt: this.dateToString(this.self.deletedAt), + } + : undefined, } } } diff --git a/database/src/queries/user.ts b/database/src/queries/user.ts index 2a1c474d7..7a072e058 100644 --- a/database/src/queries/user.ts +++ b/database/src/queries/user.ts @@ -82,6 +82,17 @@ export async function findForeignUserByUuids( }) } +export async function findUserByUuids( + communityUuid: string, + gradidoID: string, + foreign: boolean = false, +): Promise { + return DbUser.findOne({ + where: { foreign, communityUuid, gradidoID }, + relations: ['emailContact'], + }) +} + export async function findUserNamesByIds(userIds: number[]): Promise> { const users = await DbUser.find({ select: { id: true, firstName: true, lastName: true, alias: true }, diff --git a/docu/Concepts/TechnicalRequirements/image/UC_queryTransactionLink.drawio b/docu/Concepts/TechnicalRequirements/image/UC_queryTransactionLink.drawio new file mode 100644 index 000000000..32d12ac10 --- /dev/null +++ b/docu/Concepts/TechnicalRequirements/image/UC_queryTransactionLink.drawio @@ -0,0 +1,273 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/federation/src/graphql/api/1_0/resolver/CommandResolver.ts b/federation/src/graphql/api/1_0/resolver/CommandResolver.ts new file mode 100644 index 000000000..8200b5488 --- /dev/null +++ b/federation/src/graphql/api/1_0/resolver/CommandResolver.ts @@ -0,0 +1,17 @@ +import { CommandExecutor, CommandResult, EncryptedTransferArgs } from 'core' +import { Arg, Ctx, Mutation, Resolver } from 'type-graphql' + +@Resolver() +export class CommandResolver { + private commandExecutor = new CommandExecutor() + + @Mutation(() => CommandResult) + async sendCommand( + @Arg('encryptedArgs', () => EncryptedTransferArgs) encryptedArgs: any, + @Ctx() context: any, + ): Promise { + // Convert to EncryptedTransferArgs if needed + const result = await this.commandExecutor.executeEncryptedCommand(encryptedArgs) + return result as unknown as CommandResult + } +} diff --git a/federation/src/graphql/api/1_0/schema.ts b/federation/src/graphql/api/1_0/schema.ts index 90ed13cb1..ebb885755 100644 --- a/federation/src/graphql/api/1_0/schema.ts +++ b/federation/src/graphql/api/1_0/schema.ts @@ -1,5 +1,6 @@ import { NonEmptyArray } from 'type-graphql' import { AuthenticationResolver } from './resolver/AuthenticationResolver' +import { CommandResolver } from './resolver/CommandResolver' import { DisbursementResolver } from './resolver/DisbursementResolver' import { PublicCommunityInfoResolver } from './resolver/PublicCommunityInfoResolver' import { PublicKeyResolver } from './resolver/PublicKeyResolver' @@ -8,6 +9,7 @@ import { SendCoinsResolver } from './resolver/SendCoinsResolver' export const getApiResolvers = (): NonEmptyArray => { return [ AuthenticationResolver, + CommandResolver, DisbursementResolver, PublicCommunityInfoResolver, PublicKeyResolver, diff --git a/federation/src/graphql/api/1_1/schema.ts b/federation/src/graphql/api/1_1/schema.ts index 07871cefa..484fdf535 100644 --- a/federation/src/graphql/api/1_1/schema.ts +++ b/federation/src/graphql/api/1_1/schema.ts @@ -1,9 +1,16 @@ import { NonEmptyArray } from 'type-graphql' import { AuthenticationResolver } from '../1_0/resolver/AuthenticationResolver' +import { CommandResolver } from '../1_0/resolver/CommandResolver' import { PublicCommunityInfoResolver } from '../1_0/resolver/PublicCommunityInfoResolver' import { SendCoinsResolver } from '../1_0/resolver/SendCoinsResolver' import { PublicKeyResolver } from './resolver/PublicKeyResolver' export const getApiResolvers = (): NonEmptyArray => { - return [AuthenticationResolver, PublicCommunityInfoResolver, PublicKeyResolver, SendCoinsResolver] + return [ + AuthenticationResolver, + CommandResolver, + PublicCommunityInfoResolver, + PublicKeyResolver, + SendCoinsResolver, + ] } diff --git a/federation/src/index.ts b/federation/src/index.ts index 421934c58..1303aa631 100644 --- a/federation/src/index.ts +++ b/federation/src/index.ts @@ -1,6 +1,7 @@ import 'source-map-support/register' import { defaultCategory, initLogger } from 'config-schema' +import { initializeCommands } from 'core' import { getLogger } from 'log4js' import { onShutdown, printServerCrashAsciiArt, ShutdownReason } from 'shared' // config @@ -44,6 +45,8 @@ async function main() { } }) }) + + initializeCommands() } main().catch((e) => { diff --git a/shared/src/index.ts b/shared/src/index.ts index aff9f4ea9..ba3a5ad23 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -4,6 +4,7 @@ export * from './helper' export * from './jwt/JWT' export * from './jwt/payloadtypes/AuthenticationJwtPayloadType' export * from './jwt/payloadtypes/AuthenticationResponseJwtPayloadType' +export * from './jwt/payloadtypes/CommandJwtPayloadType' export * from './jwt/payloadtypes/DisburseJwtPayloadType' export * from './jwt/payloadtypes/EncryptedJWEJwtPayloadType' export * from './jwt/payloadtypes/JwtPayloadType' diff --git a/shared/src/jwt/payloadtypes/CommandJwtPayloadType.ts b/shared/src/jwt/payloadtypes/CommandJwtPayloadType.ts new file mode 100644 index 000000000..ab724c771 --- /dev/null +++ b/shared/src/jwt/payloadtypes/CommandJwtPayloadType.ts @@ -0,0 +1,22 @@ +import { JwtPayloadType } from './JwtPayloadType' + +export class CommandJwtPayloadType extends JwtPayloadType { + static COMMAND_TYPE = 'command' + + commandName: string + commandClass: string + commandArgs: string[] + + constructor( + handshakeID: string, + commandName: string, + commandClass: string, + commandArgs: string[], + ) { + super(handshakeID) + this.tokentype = CommandJwtPayloadType.COMMAND_TYPE + this.commandName = commandName + this.commandClass = commandClass + this.commandArgs = commandArgs + } +}