diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 6a2ebba87..7daf0607b 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,7 +10,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0048-add_is_moderator_to_contribution_messages', + DB_VERSION: '0049-add_user_contacts_table', DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 LOG4JS_CONFIG: 'log4js-config.json', // default log level on production should be info diff --git a/backend/src/event/Event.ts b/backend/src/event/Event.ts index 6f07661f1..85fba896d 100644 --- a/backend/src/event/Event.ts +++ b/backend/src/event/Event.ts @@ -32,6 +32,7 @@ export class EventRegister extends EventBasicUserId {} export class EventRedeemRegister extends EventBasicRedeem {} export class EventInactiveAccount extends EventBasicUserId {} export class EventSendConfirmationEmail extends EventBasicUserId {} +export class EventSendAccountMultiRegistrationEmail extends EventBasicUserId {} export class EventConfirmationEmail extends EventBasicUserId {} export class EventRegisterEmailKlicktipp extends EventBasicUserId {} export class EventLogin extends EventBasicUserId {} @@ -113,6 +114,15 @@ export class Event { return this } + public setEventSendAccountMultiRegistrationEmail( + ev: EventSendAccountMultiRegistrationEmail, + ): Event { + this.setByBasicUser(ev.userId) + this.type = EventProtocolType.SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL + + return this + } + public setEventConfirmationEmail(ev: EventConfirmationEmail): Event { this.setByBasicUser(ev.userId) this.type = EventProtocolType.CONFIRM_EMAIL diff --git a/backend/src/event/EventProtocolType.ts b/backend/src/event/EventProtocolType.ts index 0f61f787a..52bcf8349 100644 --- a/backend/src/event/EventProtocolType.ts +++ b/backend/src/event/EventProtocolType.ts @@ -5,6 +5,7 @@ export enum EventProtocolType { REDEEM_REGISTER = 'REDEEM_REGISTER', INACTIVE_ACCOUNT = 'INACTIVE_ACCOUNT', SEND_CONFIRMATION_EMAIL = 'SEND_CONFIRMATION_EMAIL', + SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL = 'SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL', CONFIRM_EMAIL = 'CONFIRM_EMAIL', REGISTER_EMAIL_KLICKTIPP = 'REGISTER_EMAIL_KLICKTIPP', LOGIN = 'LOGIN', diff --git a/backend/src/graphql/directive/isAuthorized.ts b/backend/src/graphql/directive/isAuthorized.ts index 065c01957..c24cde47a 100644 --- a/backend/src/graphql/directive/isAuthorized.ts +++ b/backend/src/graphql/directive/isAuthorized.ts @@ -31,7 +31,7 @@ const isAuthorized: AuthChecker = async ({ context }, rights) => { // TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests // TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey - const userRepository = await getCustomRepository(UserRepository) + const userRepository = getCustomRepository(UserRepository) try { const user = await userRepository.findByPubkeyHex(context.pubKey) context.user = user diff --git a/backend/src/graphql/enum/UserContactType.ts b/backend/src/graphql/enum/UserContactType.ts new file mode 100644 index 000000000..93c83830c --- /dev/null +++ b/backend/src/graphql/enum/UserContactType.ts @@ -0,0 +1,11 @@ +import { registerEnumType } from 'type-graphql' + +export enum UserContactType { + USER_CONTACT_EMAIL = 'EMAIL', + USER_CONTACT_PHONE = 'PHONE', +} + +registerEnumType(UserContactType, { + name: 'UserContactType', // this one is mandatory + description: 'Type of the user contact', // this one is optional +}) diff --git a/backend/src/graphql/model/UnconfirmedContribution.ts b/backend/src/graphql/model/UnconfirmedContribution.ts index 5847b08d0..c42b4fd11 100644 --- a/backend/src/graphql/model/UnconfirmedContribution.ts +++ b/backend/src/graphql/model/UnconfirmedContribution.ts @@ -13,7 +13,7 @@ export class UnconfirmedContribution { this.date = contribution.contributionDate this.firstName = user ? user.firstName : '' this.lastName = user ? user.lastName : '' - this.email = user ? user.email : '' + this.email = user ? user.emailContact.email : '' this.moderator = contribution.moderatorId this.creation = creations this.state = contribution.contributionStatus diff --git a/backend/src/graphql/model/User.ts b/backend/src/graphql/model/User.ts index 1ec33b27d..cc52ff1f1 100644 --- a/backend/src/graphql/model/User.ts +++ b/backend/src/graphql/model/User.ts @@ -3,6 +3,7 @@ import { KlickTipp } from './KlickTipp' import { User as dbUser } from '@entity/User' import Decimal from 'decimal.js-light' import { FULL_CREATION_AVAILABLE } from '../resolver/const/const' +import { UserContact } from './UserContact' @ObjectType() export class User { @@ -10,12 +11,16 @@ export class User { this.id = user.id this.gradidoID = user.gradidoID this.alias = user.alias - this.email = user.email + this.emailId = user.emailId + if (user.emailContact) { + this.email = user.emailContact.email + this.emailContact = new UserContact(user.emailContact) + this.emailChecked = user.emailContact.emailChecked + } this.firstName = user.firstName this.lastName = user.lastName this.deletedAt = user.deletedAt this.createdAt = user.createdAt - this.emailChecked = user.emailChecked this.language = user.language this.publisherId = user.publisherId this.isAdmin = user.isAdmin @@ -34,12 +39,18 @@ export class User { gradidoID: string @Field(() => String, { nullable: true }) - alias: string + alias?: string + + @Field(() => Number, { nullable: true }) + emailId: number | null // TODO privacy issue here - @Field(() => String) + @Field(() => String, { nullable: true }) email: string + @Field(() => UserContact) + emailContact: UserContact + @Field(() => String, { nullable: true }) firstName: string | null diff --git a/backend/src/graphql/model/UserAdmin.ts b/backend/src/graphql/model/UserAdmin.ts index cf3663e70..08dc405ac 100644 --- a/backend/src/graphql/model/UserAdmin.ts +++ b/backend/src/graphql/model/UserAdmin.ts @@ -6,11 +6,11 @@ import { User } from '@entity/User' export class UserAdmin { constructor(user: User, creation: Decimal[], hasElopage: boolean, emailConfirmationSend: string) { this.userId = user.id - this.email = user.email + this.email = user.emailContact.email this.firstName = user.firstName this.lastName = user.lastName this.creation = creation - this.emailChecked = user.emailChecked + this.emailChecked = user.emailContact.emailChecked this.hasElopage = hasElopage this.deletedAt = user.deletedAt this.emailConfirmationSend = emailConfirmationSend diff --git a/backend/src/graphql/model/UserContact.ts b/backend/src/graphql/model/UserContact.ts new file mode 100644 index 000000000..796c7f5f3 --- /dev/null +++ b/backend/src/graphql/model/UserContact.ts @@ -0,0 +1,56 @@ +import { ObjectType, Field } from 'type-graphql' +import { UserContact as dbUserContact } from '@entity/UserContact' + +@ObjectType() +export class UserContact { + constructor(userContact: dbUserContact) { + this.id = userContact.id + this.type = userContact.type + this.userId = userContact.userId + this.email = userContact.email + // this.emailVerificationCode = userContact.emailVerificationCode + this.emailOptInTypeId = userContact.emailOptInTypeId + this.emailResendCount = userContact.emailResendCount + this.emailChecked = userContact.emailChecked + this.phone = userContact.phone + this.createdAt = userContact.createdAt + this.updatedAt = userContact.updatedAt + this.deletedAt = userContact.deletedAt + } + + @Field(() => Number) + id: number + + @Field(() => String) + type: string + + @Field(() => Number) + userId: number + + @Field(() => String) + email: string + + // @Field(() => BigInt, { nullable: true }) + // emailVerificationCode: BigInt | null + + @Field(() => Number, { nullable: true }) + emailOptInTypeId: number | null + + @Field(() => Number, { nullable: true }) + emailResendCount: number | null + + @Field(() => Boolean) + emailChecked: boolean + + @Field(() => String, { nullable: true }) + phone: string | null + + @Field(() => Date) + createdAt: Date + + @Field(() => Date, { nullable: true }) + updatedAt: Date | null + + @Field(() => Date, { nullable: true }) + deletedAt: Date | null +} diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index 75c672bd5..b1b4e469e 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -1126,7 +1126,9 @@ describe('AdminResolver', () => { }), ).resolves.toEqual( expect.objectContaining({ - errors: [new GraphQLError('Could not find user with email: bob@baumeister.de')], + errors: [ + new GraphQLError('Could not find UserContact with email: bob@baumeister.de'), + ], }), ) }) @@ -1516,6 +1518,7 @@ describe('AdminResolver', () => { ) await expect(r2).resolves.toEqual( expect.objectContaining({ + // data: { confirmContribution: true }, errors: [new GraphQLError('Creation was not successful.')], }), ) diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index d71ffc72c..e9ee0b55b 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -4,8 +4,6 @@ import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type import { getCustomRepository, IsNull, - Not, - ObjectLiteral, getConnection, In, MoreThan, @@ -32,7 +30,6 @@ import { TransactionRepository } from '@repository/Transaction' import { calculateDecay } from '@/util/decay' import { Contribution } from '@entity/Contribution' import { hasElopageBuys } from '@/util/hasElopageBuys' -import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { User as dbUser } from '@entity/User' import { User } from '@model/User' import { TransactionTypeId } from '@enum/TransactionTypeId' @@ -44,7 +41,7 @@ import Paginated from '@arg/Paginated' import TransactionLinkFilters from '@arg/TransactionLinkFilters' import { Order } from '@enum/Order' import { communityUser } from '@/util/communityUser' -import { checkOptInCode, activationLink, printTimeDuration } from './UserResolver' +import { findUserByEmail, activationLink, printTimeDuration } from './UserResolver' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' import CONFIG from '@/config' @@ -62,6 +59,7 @@ import { MEMO_MAX_CHARS, MEMO_MIN_CHARS, } from './const/const' +import { UserContact } from '@entity/UserContact' import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage' import ContributionMessageArgs from '@arg/ContributionMessageArgs' import { ContributionMessageType } from '@enum/MessageType' @@ -81,24 +79,12 @@ export class AdminResolver { { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, ): Promise { const userRepository = getCustomRepository(UserRepository) - - const filterCriteria: ObjectLiteral[] = [] - if (filters) { - if (filters.byActivated !== null) { - filterCriteria.push({ emailChecked: filters.byActivated }) - } - - if (filters.byDeleted !== null) { - filterCriteria.push({ deletedAt: filters.byDeleted ? Not(IsNull()) : IsNull() }) - } - } - const userFields = [ 'id', 'firstName', 'lastName', - 'email', - 'emailChecked', + 'emailId', + 'emailContact', 'deletedAt', 'isAdmin', ] @@ -107,7 +93,7 @@ export class AdminResolver { return 'user.' + fieldName }), searchText, - filterCriteria, + filters, currentPage, pageSize, ) @@ -124,32 +110,18 @@ export class AdminResolver { const adminUsers = await Promise.all( users.map(async (user) => { let emailConfirmationSend = '' - if (!user.emailChecked) { - const emailOptIn = await LoginEmailOptIn.findOne( - { - userId: user.id, - }, - { - order: { - updatedAt: 'DESC', - createdAt: 'DESC', - }, - select: ['updatedAt', 'createdAt'], - }, - ) - if (emailOptIn) { - if (emailOptIn.updatedAt) { - emailConfirmationSend = emailOptIn.updatedAt.toISOString() - } else { - emailConfirmationSend = emailOptIn.createdAt.toISOString() - } + if (!user.emailContact.emailChecked) { + if (user.emailContact.updatedAt) { + emailConfirmationSend = user.emailContact.updatedAt.toISOString() + } else { + emailConfirmationSend = user.emailContact.createdAt.toISOString() } } const userCreations = creations.find((c) => c.id === user.id) const adminUser = new UserAdmin( user, userCreations ? userCreations.creations : FULL_CREATION_AVAILABLE, - await hasElopageBuys(user.email), + await hasElopageBuys(user.emailContact.email), emailConfirmationSend, ) return adminUser @@ -245,24 +217,39 @@ export class AdminResolver { @Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs, @Ctx() context: Context, ): Promise { - const user = await dbUser.findOne({ email }, { withDeleted: true }) - if (!user) { + logger.info( + `adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`, + ) + const emailContact = await UserContact.findOne({ + where: { email }, + withDeleted: true, + relations: ['user'], + }) + if (!emailContact) { + logger.error(`Could not find user with email: ${email}`) throw new Error(`Could not find user with email: ${email}`) } - if (user.deletedAt) { + if (emailContact.deletedAt) { + logger.error('This emailContact was deleted. Cannot create a contribution.') + throw new Error('This emailContact was deleted. Cannot create a contribution.') + } + if (emailContact.user.deletedAt) { + logger.error('This user was deleted. Cannot create a contribution.') throw new Error('This user was deleted. Cannot create a contribution.') } - if (!user.emailChecked) { + if (!emailContact.emailChecked) { + logger.error('Contribution could not be saved, Email is not activated') throw new Error('Contribution could not be saved, Email is not activated') } const moderator = getUser(context) logger.trace('moderator: ', moderator.id) - const creations = await getUserCreation(user.id) - logger.trace('creations', creations) + const creations = await getUserCreation(emailContact.userId) + logger.trace('creations:', creations) const creationDateObj = new Date(creationDate) + logger.trace('creationDateObj:', creationDateObj) validateContribution(creations, amount, creationDateObj) const contribution = Contribution.create() - contribution.userId = user.id + contribution.userId = emailContact.userId contribution.amount = amount contribution.createdAt = new Date() contribution.contributionDate = creationDateObj @@ -273,7 +260,7 @@ export class AdminResolver { logger.trace('contribution to save', contribution) await Contribution.save(contribution) - return getUserCreation(user.id) + return getUserCreation(emailContact.userId) } @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS]) @@ -309,11 +296,22 @@ export class AdminResolver { @Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs, @Ctx() context: Context, ): Promise { - const user = await dbUser.findOne({ email }, { withDeleted: true }) + const emailContact = await UserContact.findOne({ + where: { email }, + withDeleted: true, + relations: ['user'], + }) + if (!emailContact) { + logger.error(`Could not find UserContact with email: ${email}`) + throw new Error(`Could not find UserContact with email: ${email}`) + } + const user = emailContact.user if (!user) { - throw new Error(`Could not find user with email: ${email}`) + logger.error(`Could not find User to emailContact: ${email}`) + throw new Error(`Could not find User to emailContact: ${email}`) } if (user.deletedAt) { + logger.error(`User was deleted (${email})`) throw new Error(`User was deleted (${email})`) } @@ -324,14 +322,17 @@ export class AdminResolver { }) if (!contributionToUpdate) { + logger.error('No contribution found to given id.') throw new Error('No contribution found to given id.') } if (contributionToUpdate.userId !== user.id) { + logger.error('user of the pending contribution and send user does not correspond') throw new Error('user of the pending contribution and send user does not correspond') } if (contributionToUpdate.moderatorId === null) { + logger.error('An admin is not allowed to update a user contribution.') throw new Error('An admin is not allowed to update a user contribution.') } @@ -377,7 +378,11 @@ export class AdminResolver { const userIds = contributions.map((p) => p.userId) const userCreations = await getUserCreations(userIds) - const users = await dbUser.find({ where: { id: In(userIds) }, withDeleted: true }) + const users = await dbUser.find({ + where: { id: In(userIds) }, + withDeleted: true, + relations: ['emailContact'], + }) return contributions.map((contribution) => { const user = users.find((u) => u.id === contribution.userId) @@ -396,6 +401,7 @@ export class AdminResolver { async adminDeleteContribution(@Arg('id', () => Int) id: number): Promise { const contribution = await Contribution.findOne(id) if (!contribution) { + logger.error(`Contribution not found for given id: ${id}`) throw new Error('Contribution not found for given id.') } contribution.contributionStatus = ContributionStatus.DELETED @@ -412,15 +418,22 @@ export class AdminResolver { ): Promise { const contribution = await Contribution.findOne(id) if (!contribution) { + logger.error(`Contribution not found for given id: ${id}`) throw new Error('Contribution not found to given id.') } const moderatorUser = getUser(context) - if (moderatorUser.id === contribution.userId) + if (moderatorUser.id === contribution.userId) { + logger.error('Moderator can not confirm own contribution') throw new Error('Moderator can not confirm own contribution') - - const user = await dbUser.findOneOrFail({ id: contribution.userId }, { withDeleted: true }) - if (user.deletedAt) throw new Error('This user was deleted. Cannot confirm a contribution.') - + } + const user = await dbUser.findOneOrFail( + { id: contribution.userId }, + { withDeleted: true, relations: ['emailContact'] }, + ) + if (user.deletedAt) { + logger.error('This user was deleted. Cannot confirm a contribution.') + throw new Error('This user was deleted. Cannot confirm a contribution.') + } const creations = await getUserCreation(contribution.userId, false) validateContribution(creations, contribution.amount, contribution.contributionDate) @@ -428,7 +441,7 @@ export class AdminResolver { const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() - await queryRunner.startTransaction('READ UNCOMMITTED') + await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED') try { const lastTransaction = await queryRunner.manager .createQueryBuilder() @@ -477,7 +490,7 @@ export class AdminResolver { senderLastName: moderatorUser.lastName, recipientFirstName: user.firstName, recipientLastName: user.lastName, - recipientEmail: user.email, + recipientEmail: user.emailContact.email, contributionMemo: contribution.memo, contributionAmount: contribution.amount, overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, @@ -517,32 +530,35 @@ export class AdminResolver { @Mutation(() => Boolean) async sendActivationEmail(@Arg('email') email: string): Promise { email = email.trim().toLowerCase() - const user = await dbUser.findOneOrFail({ email: email }) - - // can be both types: REGISTER and RESET_PASSWORD - let optInCode = await LoginEmailOptIn.findOne({ - where: { userId: user.id }, - order: { updatedAt: 'DESC' }, - }) - - optInCode = await checkOptInCode(optInCode, user) + // const user = await dbUser.findOne({ id: emailContact.userId }) + const user = await findUserByEmail(email) + if (!user) { + logger.error(`Could not find User to emailContact: ${email}`) + throw new Error(`Could not find User to emailContact: ${email}`) + } + if (user.deletedAt) { + logger.error(`User with emailContact: ${email} is deleted.`) + throw new Error(`User with emailContact: ${email} is deleted.`) + } + const emailContact = user.emailContact + if (emailContact.deletedAt) { + logger.error(`The emailContact: ${email} of htis User is deleted.`) + throw new Error(`The emailContact: ${email} of htis User is deleted.`) + } // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendAccountActivationEmail({ - link: activationLink(optInCode), + link: activationLink(emailContact.emailVerificationCode), firstName: user.firstName, lastName: user.lastName, email, duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME), }) - /* uncomment this, when you need the activation link on the console // In case EMails are disabled log the activation link for the user if (!emailSent) { - // eslint-disable-next-line no-console - console.log(`Account confirmation link: ${activationLink}`) + logger.info(`Account confirmation link: ${activationLink}`) } - */ return true } @@ -720,9 +736,12 @@ export class AdminResolver { @Ctx() context: Context, ): Promise { const user = getUser(context) + if (!user.emailContact) { + user.emailContact = await UserContact.findOneOrFail({ where: { id: user.emailId } }) + } const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() - await queryRunner.startTransaction('READ UNCOMMITTED') + await queryRunner.startTransaction('REPEATABLE READ') const contributionMessage = DbContributionMessage.create() try { const contribution = await Contribution.findOne({ @@ -735,6 +754,11 @@ export class AdminResolver { if (contribution.userId === user.id) { throw new Error('Admin can not answer on own contribution') } + if (!contribution.user.emailContact) { + contribution.user.emailContact = await UserContact.findOneOrFail({ + where: { id: contribution.user.emailId }, + }) + } contributionMessage.contributionId = contributionId contributionMessage.createdAt = new Date() contributionMessage.message = message @@ -751,19 +775,19 @@ export class AdminResolver { contribution.contributionStatus = ContributionStatus.IN_PROGRESS await queryRunner.manager.update(Contribution, { id: contributionId }, contribution) } - await queryRunner.commitTransaction() await sendAddedContributionMessageEmail({ senderFirstName: user.firstName, senderLastName: user.lastName, recipientFirstName: contribution.user.firstName, recipientLastName: contribution.user.lastName, - recipientEmail: contribution.user.email, - senderEmail: user.email, + recipientEmail: contribution.user.emailContact.email, + senderEmail: user.emailContact.email, contributionMemo: contribution.memo, message, overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, }) + await queryRunner.commitTransaction() } catch (e) { await queryRunner.rollbackTransaction() logger.error(`ContributionMessage was not successful: ${e}`) diff --git a/backend/src/graphql/resolver/ContributionMessageResolver.ts b/backend/src/graphql/resolver/ContributionMessageResolver.ts index fb92806d0..0b33c4722 100644 --- a/backend/src/graphql/resolver/ContributionMessageResolver.ts +++ b/backend/src/graphql/resolver/ContributionMessageResolver.ts @@ -23,7 +23,7 @@ export class ContributionMessageResolver { const user = getUser(context) const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() - await queryRunner.startTransaction('READ UNCOMMITTED') + await queryRunner.startTransaction('REPEATABLE READ') const contributionMessage = DbContributionMessage.create() try { const contribution = await Contribution.findOne({ id: contributionId }) diff --git a/backend/src/graphql/resolver/GdtResolver.ts b/backend/src/graphql/resolver/GdtResolver.ts index 56a95c9f0..a1d75e946 100644 --- a/backend/src/graphql/resolver/GdtResolver.ts +++ b/backend/src/graphql/resolver/GdtResolver.ts @@ -20,7 +20,7 @@ export class GdtResolver { try { const resultGDT = await apiGet( - `${CONFIG.GDT_API_URL}/GdtEntries/listPerEmailApi/${userEntity.email}/${currentPage}/${pageSize}/${order}`, + `${CONFIG.GDT_API_URL}/GdtEntries/listPerEmailApi/${userEntity.emailContact.email}/${currentPage}/${pageSize}/${order}`, ) if (!resultGDT.success) { throw new Error(resultGDT.data) @@ -37,7 +37,7 @@ export class GdtResolver { const user = getUser(context) try { const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, { - email: user.email, + email: user.emailContact.email, }) if (!resultGDTSum.success) { throw new Error('Call not successful') diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index ccc0f628d..c9acbace3 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -178,7 +178,7 @@ export class TransactionLinkResolver { logger.info('redeem contribution link...') const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() - await queryRunner.startTransaction('SERIALIZABLE') + await queryRunner.startTransaction('REPEATABLE READ') try { const contributionLink = await queryRunner.manager .createQueryBuilder() @@ -283,7 +283,10 @@ export class TransactionLinkResolver { return true } else { const transactionLink = await dbTransactionLink.findOneOrFail({ code }) - const linkedUser = await dbUser.findOneOrFail({ id: transactionLink.userId }) + const linkedUser = await dbUser.findOneOrFail( + { id: transactionLink.userId }, + { relations: ['emailContact'] }, + ) if (user.id === linkedUser.id) { throw new Error('Cannot redeem own transaction link.') diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index c192ae9dc..b00d84de6 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -35,6 +35,7 @@ import Decimal from 'decimal.js-light' import { BalanceResolver } from './BalanceResolver' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' +import { findUserByEmail } from './UserResolver' import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed' export const executeTransaction = async ( @@ -79,7 +80,7 @@ export const executeTransaction = async ( const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() - await queryRunner.startTransaction('READ UNCOMMITTED') + await queryRunner.startTransaction('REPEATABLE READ') logger.debug(`open Transaction to write...`) try { // transaction @@ -149,8 +150,8 @@ export const executeTransaction = async ( senderLastName: sender.lastName, recipientFirstName: recipient.firstName, recipientLastName: recipient.lastName, - email: recipient.email, - senderEmail: sender.email, + email: recipient.emailContact.email, + senderEmail: sender.emailContact.email, amount, overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, }) @@ -160,8 +161,8 @@ export const executeTransaction = async ( senderLastName: recipient.lastName, recipientFirstName: sender.firstName, recipientLastName: sender.lastName, - email: sender.email, - senderEmail: recipient.email, + email: sender.emailContact.email, + senderEmail: recipient.emailContact.email, amount, memo, overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, @@ -184,7 +185,7 @@ export class TransactionResolver { const user = getUser(context) logger.addContext('user', user.id) - logger.info(`transactionList(user=${user.firstName}.${user.lastName}, ${user.email})`) + logger.info(`transactionList(user=${user.firstName}.${user.lastName}, ${user.emailId})`) // find current balance const lastTransaction = await dbTransaction.findOne( @@ -306,16 +307,25 @@ export class TransactionResolver { } // validate recipient user - const recipientUser = await dbUser.findOne({ email: email }, { withDeleted: true }) + const recipientUser = await findUserByEmail(email) + /* + const emailContact = await UserContact.findOne({ email }, { withDeleted: true }) + if (!emailContact) { + logger.error(`Could not find UserContact with email: ${email}`) + throw new Error(`Could not find UserContact with email: ${email}`) + } + */ + // const recipientUser = await dbUser.findOne({ id: emailContact.userId }) if (!recipientUser) { - logger.error(`recipient not known: email=${email}`) - throw new Error('recipient not known') + logger.error(`unknown recipient to UserContact: email=${email}`) + throw new Error('unknown recipient') } if (recipientUser.deletedAt) { logger.error(`The recipient account was deleted: recipientUser=${recipientUser}`) throw new Error('The recipient account was deleted') } - if (!recipientUser.emailChecked) { + const emailContact = recipientUser.emailContact + if (!emailContact.emailChecked) { logger.error(`The recipient account is not activated: recipientUser=${recipientUser}`) throw new Error('The recipient account is not activated') } diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index 7ba74aff8..53dc392ba 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { testEnvironment, headerPushMock, resetToken, cleanDB, resetEntity } from '@test/helpers' +import { testEnvironment, headerPushMock, resetToken, cleanDB } from '@test/helpers' import { userFactory } from '@/seeds/factory/user' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { @@ -14,7 +14,6 @@ import { } from '@/seeds/graphql/mutations' import { login, logout, verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries' import { GraphQLError } from 'graphql' -import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { User } from '@entity/User' import CONFIG from '@/config' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' @@ -31,6 +30,9 @@ import { EventProtocol } from '@entity/EventProtocol' import { logger } from '@test/testSetup' import { validate as validateUUID, version as versionUUID } from 'uuid' import { peterLustig } from '@/seeds/users/peter-lustig' +import { UserContact } from '@entity/UserContact' +import { OptInType } from '../enum/OptInType' +import { UserContactType } from '../enum/UserContactType' import { bobBaumeister } from '@/seeds/users/bob-baumeister' // import { klicktippSignIn } from '@/apis/KlicktippController' @@ -92,7 +94,7 @@ describe('UserResolver', () => { } let result: any - let emailOptIn: string + let emailVerificationCode: string let user: User[] beforeAll(async () => { @@ -111,11 +113,11 @@ describe('UserResolver', () => { }) describe('valid input data', () => { - let loginEmailOptIn: LoginEmailOptIn[] + // let loginEmailOptIn: LoginEmailOptIn[] beforeAll(async () => { - user = await User.find() - loginEmailOptIn = await LoginEmailOptIn.find() - emailOptIn = loginEmailOptIn[0].verificationCode.toString() + user = await User.find({ relations: ['emailContact'] }) + // loginEmailOptIn = await LoginEmailOptIn.find() + emailVerificationCode = user[0].emailContact.emailVerificationCode.toString() }) describe('filling all tables', () => { @@ -125,15 +127,16 @@ describe('UserResolver', () => { id: expect.any(Number), gradidoID: expect.any(String), alias: null, - email: 'peter@lustig.de', + emailContact: expect.any(UserContact), // 'peter@lustig.de', + emailId: expect.any(Number), firstName: 'Peter', lastName: 'Lustig', password: '0', pubKey: null, privKey: null, - emailHash: expect.any(Buffer), + // emailHash: expect.any(Buffer), createdAt: expect.any(Date), - emailChecked: false, + // emailChecked: false, passphrase: expect.any(String), language: 'de', isAdmin: null, @@ -149,18 +152,21 @@ describe('UserResolver', () => { expect(verUUID).toEqual(4) }) - it('creates an email optin', () => { - expect(loginEmailOptIn).toEqual([ - { - id: expect.any(Number), - userId: user[0].id, - verificationCode: expect.any(String), - emailOptInTypeId: 1, - createdAt: expect.any(Date), - resendCount: 0, - updatedAt: expect.any(Date), - }, - ]) + it('creates an email contact', () => { + expect(user[0].emailContact).toEqual({ + id: expect.any(Number), + type: UserContactType.USER_CONTACT_EMAIL, + userId: user[0].id, + email: 'peter@lustig.de', + emailChecked: false, + emailVerificationCode: expect.any(String), + emailOptInTypeId: OptInType.EMAIL_OPT_IN_REGISTER, + emailResendCount: 0, + phone: null, + createdAt: expect.any(Date), + deletedAt: null, + updatedAt: null, + }) }) }) }) @@ -169,7 +175,7 @@ describe('UserResolver', () => { it('sends an account activation email', () => { const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace( /{optin}/g, - emailOptIn, + emailVerificationCode, ).replace(/{code}/g, '') expect(sendAccountActivationEmail).toBeCalledWith({ link: activationLink, @@ -227,13 +233,13 @@ describe('UserResolver', () => { mutation: createUser, variables: { ...variables, email: 'bibi@bloxberg.de', language: 'it' }, }) - await expect(User.find()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - email: 'bibi@bloxberg.de', - language: 'de', - }), - ]), + await expect( + UserContact.findOne({ email: 'bibi@bloxberg.de' }, { relations: ['user'] }), + ).resolves.toEqual( + expect.objectContaining({ + email: 'bibi@bloxberg.de', + user: expect.objectContaining({ language: 'de' }), + }), ) }) }) @@ -244,10 +250,12 @@ describe('UserResolver', () => { mutation: createUser, variables: { ...variables, email: 'raeuber@hotzenplotz.de', publisherId: undefined }, }) - await expect(User.find()).resolves.toEqual( + await expect(User.find({ relations: ['emailContact'] })).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - email: 'raeuber@hotzenplotz.de', + emailContact: expect.objectContaining({ + email: 'raeuber@hotzenplotz.de', + }), publisherId: null, }), ]), @@ -264,7 +272,7 @@ describe('UserResolver', () => { // activate account of admin Peter Lustig await mutate({ mutation: setPassword, - variables: { code: emailOptIn, password: 'Aa12345_' }, + variables: { code: emailVerificationCode, password: 'Aa12345_' }, }) // make Peter Lustig Admin @@ -298,9 +306,13 @@ describe('UserResolver', () => { }) it('sets the contribution link id', async () => { - await expect(User.findOne({ email: 'ein@besucher.de' })).resolves.toEqual( + await expect( + UserContact.findOne({ email: 'ein@besucher.de' }, { relations: ['user'] }), + ).resolves.toEqual( expect.objectContaining({ - contributionLinkId: link.id, + user: expect.objectContaining({ + contributionLinkId: link.id, + }), }), ) }) @@ -389,8 +401,12 @@ describe('UserResolver', () => { }) it('sets the referrer id to bob baumeister id', async () => { - await expect(User.findOne({ email: 'which@ever.de' })).resolves.toEqual( - expect.objectContaining({ referrerId: bob.data.login.id }), + await expect( + UserContact.findOne({ email: 'which@ever.de' }, { relations: ['user'] }), + ).resolves.toEqual( + expect.objectContaining({ + user: expect.objectContaining({ referrerId: bob.data.login.id }), + }), ) }) @@ -413,7 +429,7 @@ describe('UserResolver', () => { email: 'peter@lustig.de', amount: 19.99, memo: `Kein Trick, keine Zauberrei, -bei Gradidio sei dabei!`, + bei Gradidio sei dabei!`, }) const transactionLink = await TransactionLink.findOneOrFail() resetToken() @@ -422,14 +438,14 @@ bei Gradidio sei dabei!`, variables: { ...variables, email: 'neuer@user.de', redeemCode: transactionLink.code }, }) }) - + it('sets the referrer id to Peter Lustigs id', async () => { await expect(User.findOne({ email: 'neuer@user.de' })).resolves.toEqual(expect.objectContaining({ referrerId: user[0].id, })) }) }) - + */ }) }) @@ -444,20 +460,23 @@ bei Gradidio sei dabei!`, } let result: any - let emailOptIn: string + let emailVerificationCode: string describe('valid optin code and valid password', () => { - let newUser: any + let newUser: User beforeAll(async () => { await mutate({ mutation: createUser, variables: createUserVariables }) - const loginEmailOptIn = await LoginEmailOptIn.find() - emailOptIn = loginEmailOptIn[0].verificationCode.toString() + const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email }) + emailVerificationCode = emailContact.emailVerificationCode.toString() result = await mutate({ mutation: setPassword, - variables: { code: emailOptIn, password: 'Aa12345_' }, + variables: { code: emailVerificationCode, password: 'Aa12345_' }, }) - newUser = await User.find() + newUser = await User.findOneOrFail( + { id: emailContact.userId }, + { relations: ['emailContact'] }, + ) }) afterAll(async () => { @@ -465,11 +484,11 @@ bei Gradidio sei dabei!`, }) it('sets email checked to true', () => { - expect(newUser[0].emailChecked).toBeTruthy() + expect(newUser.emailContact.emailChecked).toBeTruthy() }) it('updates the password', () => { - expect(newUser[0].password).toEqual('3917921995996627700') + expect(newUser.password).toEqual('3917921995996627700') }) /* @@ -491,11 +510,11 @@ bei Gradidio sei dabei!`, describe('no valid password', () => { beforeAll(async () => { await mutate({ mutation: createUser, variables: createUserVariables }) - const loginEmailOptIn = await LoginEmailOptIn.find() - emailOptIn = loginEmailOptIn[0].verificationCode.toString() + const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email }) + emailVerificationCode = emailContact.emailVerificationCode.toString() result = await mutate({ mutation: setPassword, - variables: { code: emailOptIn, password: 'not-valid' }, + variables: { code: emailVerificationCode, password: 'not-valid' }, }) }) @@ -562,6 +581,7 @@ bei Gradidio sei dabei!`, describe('no users in database', () => { beforeAll(async () => { + jest.clearAllMocks() result = await query({ query: login, variables }) }) @@ -574,7 +594,9 @@ bei Gradidio sei dabei!`, }) it('logs the error found', () => { - expect(logger.error).toBeCalledWith('User with email=bibi@bloxberg.de does not exist') + expect(logger.error).toBeCalledWith( + 'UserContact with email=bibi@bloxberg.de does not exists', + ) }) }) @@ -759,46 +781,68 @@ bei Gradidio sei dabei!`, describe('forgotPassword', () => { const variables = { email: 'bibi@bloxberg.de' } + const emailCodeRequestTime = CONFIG.EMAIL_CODE_REQUEST_TIME + describe('user is not in DB', () => { - it('returns true', async () => { - await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual( - expect.objectContaining({ - data: { - forgotPassword: true, - }, - }), - ) + describe('duration not expired', () => { + it('returns true', async () => { + await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual( + expect.objectContaining({ + data: { + forgotPassword: true, + }, + }), + ) + }) }) }) describe('user exists in DB', () => { - let result: any - let loginEmailOptIn: LoginEmailOptIn[] + let emailContact: UserContact beforeAll(async () => { await userFactory(testEnv, bibiBloxberg) - await resetEntity(LoginEmailOptIn) - result = await mutate({ mutation: forgotPassword, variables }) - loginEmailOptIn = await LoginEmailOptIn.find() + // await resetEntity(LoginEmailOptIn) + emailContact = await UserContact.findOneOrFail(variables) }) afterAll(async () => { await cleanDB() + CONFIG.EMAIL_CODE_REQUEST_TIME = emailCodeRequestTime }) - it('returns true', async () => { - await expect(result).toEqual( - expect.objectContaining({ - data: { - forgotPassword: true, - }, - }), - ) + describe('duration not expired', () => { + it('returns true', async () => { + await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `email already sent less than ${printTimeDuration( + CONFIG.EMAIL_CODE_REQUEST_TIME, + )} minutes ago`, + ), + ], + }), + ) + }) + }) + + describe('duration reset to 0', () => { + it('returns true', async () => { + CONFIG.EMAIL_CODE_REQUEST_TIME = 0 + await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual( + expect.objectContaining({ + data: { + forgotPassword: true, + }, + }), + ) + }) }) it('sends reset password email', () => { expect(sendResetPasswordEmail).toBeCalledWith({ - link: activationLink(loginEmailOptIn[0]), + link: activationLink(emailContact.emailVerificationCode), firstName: 'Bibi', lastName: 'Bloxberg', email: 'bibi@bloxberg.de', @@ -807,7 +851,8 @@ bei Gradidio sei dabei!`, }) describe('request reset password again', () => { - it('throws an error', async () => { + it('thows an error', async () => { + CONFIG.EMAIL_CODE_REQUEST_TIME = emailCodeRequestTime await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual( expect.objectContaining({ errors: [new GraphQLError('email already sent less than 10 minutes minutes ago')], @@ -823,11 +868,11 @@ bei Gradidio sei dabei!`, }) describe('queryOptIn', () => { - let loginEmailOptIn: LoginEmailOptIn[] + let emailContact: UserContact beforeAll(async () => { await userFactory(testEnv, bibiBloxberg) - loginEmailOptIn = await LoginEmailOptIn.find() + emailContact = await UserContact.findOneOrFail({ email: bibiBloxberg.email }) }) afterAll(async () => { @@ -842,8 +887,8 @@ bei Gradidio sei dabei!`, expect.objectContaining({ errors: [ // keep Whitspace in error message! - new GraphQLError(`Could not find any entity of type "LoginEmailOptIn" matching: { - "verificationCode": "not-valid" + new GraphQLError(`Could not find any entity of type "UserContact" matching: { + "emailVerificationCode": "not-valid" }`), ], }), @@ -856,7 +901,7 @@ bei Gradidio sei dabei!`, await expect( query({ query: queryOptIn, - variables: { optIn: loginEmailOptIn[0].verificationCode.toString() }, + variables: { optIn: emailContact.emailVerificationCode.toString() }, }), ).resolves.toEqual( expect.objectContaining({ diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 18cd8134b..5ad578767 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -1,12 +1,12 @@ import fs from 'fs' import { backendLogger as logger } from '@/server/logger' - import { Context, getUser } from '@/server/context' import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm' import CONFIG from '@/config' import { User } from '@model/User' import { User as DbUser } from '@entity/User' +import { UserContact as DbUserContact } from '@entity/UserContact' import { communityDbUser } from '@/util/communityUser' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' import { ContributionLink as dbContributionLink } from '@entity/ContributionLink' @@ -16,7 +16,6 @@ import UnsecureLoginArgs from '@arg/UnsecureLoginArgs' import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs' import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware' import { OptInType } from '@enum/OptInType' -import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail' @@ -29,10 +28,12 @@ import { EventLogin, EventRedeemRegister, EventRegister, + EventSendAccountMultiRegistrationEmail, EventSendConfirmationEmail, EventActivateAccount, } from '@/event/Event' import { getUserCreation } from './util/creations' +import { UserContactType } from '../enum/UserContactType' import { UserRepository } from '@/typeorm/repository/User' import { SearchAdminUsersResult } from '@model/AdminUser' import Paginated from '@arg/Paginated' @@ -147,6 +148,7 @@ const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[ return [encryptionKeyHash, encryptionKey] } +/* const getEmailHash = (email: string): Buffer => { logger.trace('getEmailHash...') const emailHash = Buffer.alloc(sodium.crypto_generichash_BYTES) @@ -154,6 +156,7 @@ const getEmailHash = (email: string): Buffer => { logger.debug(`getEmailHash...successful: ${emailHash}`) return emailHash } +*/ const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => { logger.trace('SecretKeyCryptographyEncrypt...') @@ -178,6 +181,19 @@ const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: B return message } +const newEmailContact = (email: string, userId: number): DbUserContact => { + logger.trace(`newEmailContact...`) + const emailContact = new DbUserContact() + emailContact.email = email + emailContact.userId = userId + emailContact.type = UserContactType.USER_CONTACT_EMAIL + emailContact.emailChecked = false + emailContact.emailOptInTypeId = OptInType.EMAIL_OPT_IN_REGISTER + emailContact.emailVerificationCode = random(64) + logger.debug(`newEmailContact...successful: ${emailContact}`) + return emailContact +} +/* const newEmailOptIn = (userId: number): LoginEmailOptIn => { logger.trace('newEmailOptIn...') const emailOptIn = new LoginEmailOptIn() @@ -187,7 +203,8 @@ const newEmailOptIn = (userId: number): LoginEmailOptIn => { logger.debug(`newEmailOptIn...successful: ${emailOptIn}`) return emailOptIn } - +*/ +/* // needed by AdminResolver // checks if given code exists and can be resent // if optIn does not exits, it is created @@ -227,10 +244,44 @@ export const checkOptInCode = async ( logger.debug(`checkOptInCode...successful: ${optInCode} for userid=${user.id}`) return optInCode } +*/ +export const checkEmailVerificationCode = async ( + emailContact: DbUserContact, + optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER, +): Promise => { + logger.info(`checkEmailVerificationCode... ${emailContact}`) + if (emailContact.updatedAt) { + if (!canEmailResend(emailContact.updatedAt)) { + logger.error( + `email already sent less than ${printTimeDuration( + CONFIG.EMAIL_CODE_REQUEST_TIME, + )} minutes ago`, + ) + throw new Error( + `email already sent less than ${printTimeDuration( + CONFIG.EMAIL_CODE_REQUEST_TIME, + )} minutes ago`, + ) + } + emailContact.updatedAt = new Date() + emailContact.emailResendCount++ + } else { + logger.trace('create new EmailVerificationCode for userId=' + emailContact.userId) + emailContact.emailChecked = false + emailContact.emailVerificationCode = random(64) + } + emailContact.emailOptInTypeId = optInType + await DbUserContact.save(emailContact).catch(() => { + logger.error('Unable to save email verification code= ' + emailContact) + throw new Error('Unable to save email verification code.') + }) + logger.debug(`checkEmailVerificationCode...successful: ${emailContact}`) + return emailContact +} -export const activationLink = (optInCode: LoginEmailOptIn): string => { - logger.debug(`activationLink(${LoginEmailOptIn})...`) - return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, optInCode.verificationCode.toString()) +export const activationLink = (verificationCode: BigInt): string => { + logger.debug(`activationLink(${verificationCode})...`) + return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, verificationCode.toString()) } const newGradidoID = async (): Promise => { @@ -273,15 +324,12 @@ export class UserResolver { ): Promise { logger.info(`login with ${email}, ***, ${publisherId} ...`) email = email.trim().toLowerCase() - const dbUser = await DbUser.findOneOrFail({ email }, { withDeleted: true }).catch(() => { - logger.error(`User with email=${email} does not exist`) - throw new Error('No user with this credentials') - }) + const dbUser = await findUserByEmail(email) if (dbUser.deletedAt) { logger.error('The User was permanently deleted in database.') throw new Error('This user was permanently deleted. Contact support for questions.') } - if (!dbUser.emailChecked) { + if (!dbUser.emailContact.emailChecked) { logger.error('The Users email is not validate yet.') throw new Error('User email not validated') } @@ -306,7 +354,7 @@ export class UserResolver { logger.debug('login credentials valid...') const user = new User(dbUser, await getUserCreation(dbUser.id)) - logger.debug('user=' + user) + logger.debug(`user= ${JSON.stringify(user, null, 2)}`) // Elopage Status & Stored PublisherId user.hasElopage = await this.hasElopage({ ...context, user: dbUser }) @@ -324,7 +372,7 @@ export class UserResolver { const ev = new EventLogin() ev.userId = user.id eventProtocol.writeEvent(new Event().setEventLogin(ev)) - logger.info('successful Login:' + user) + logger.info(`successful Login: ${JSON.stringify(user, null, 2)}`) return user } @@ -353,66 +401,72 @@ export class UserResolver { ) // TODO: wrong default value (should be null), how does graphql work here? Is it an required field? // default int publisher_id = 0; + const event = new Event() // Validate Language (no throw) if (!language || !isLanguage(language)) { language = DEFAULT_LANGUAGE } - // Validate email unique + // check if user with email still exists? email = email.trim().toLowerCase() - // TODO we cannot use repository.count(), since it does not allow to specify if you want to include the soft deletes - const userFound = await DbUser.findOne({ email }, { withDeleted: true }) - logger.info(`DbUser.findOne(email=${email}) = ${userFound}`) + if (await checkEmailExists(email)) { + const foundUser = await findUserByEmail(email) + logger.info(`DbUser.findOne(email=${email}) = ${foundUser}`) - if (userFound) { - // ATTENTION: this logger-message will be exactly expected during tests - logger.info(`User already exists with this email=${email}`) - // TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent. + if (foundUser) { + // ATTENTION: this logger-message will be exactly expected during tests + logger.info(`User already exists with this email=${email}`) + // TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent. - const user = new User(communityDbUser) - user.id = sodium.randombytes_random() % (2048 * 16) // TODO: for a better faking derive id from email so that it will be always the same id when the same email comes in? - user.gradidoID = uuidv4() - user.email = email - user.firstName = firstName - user.lastName = lastName - user.language = language - user.publisherId = publisherId - logger.debug('partly faked user=' + user) + const user = new User(communityDbUser) + user.id = sodium.randombytes_random() % (2048 * 16) // TODO: for a better faking derive id from email so that it will be always the same id when the same email comes in? + user.gradidoID = uuidv4() + user.email = email + user.firstName = firstName + user.lastName = lastName + user.language = language + user.publisherId = publisherId + logger.debug('partly faked user=' + user) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const emailSent = await sendAccountMultiRegistrationEmail({ - firstName, - lastName, - email, - }) - logger.info(`sendAccountMultiRegistrationEmail of ${firstName}.${lastName} to ${email}`) - /* uncomment this, when you need the activation link on the console */ - // In case EMails are disabled log the activation link for the user - if (!emailSent) { - logger.debug(`Email not sent!`) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const emailSent = await sendAccountMultiRegistrationEmail({ + firstName, + lastName, + email, + }) + const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail() + eventSendAccountMultiRegistrationEmail.userId = foundUser.id + eventProtocol.writeEvent( + event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail), + ) + logger.info(`sendAccountMultiRegistrationEmail of ${firstName}.${lastName} to ${email}`) + /* uncomment this, when you need the activation link on the console */ + // In case EMails are disabled log the activation link for the user + if (!emailSent) { + logger.debug(`Email not send!`) + } + logger.info('createUser() faked and send multi registration mail...') + + return user } - logger.info('createUser() faked and send multi registration mail...') - - return user } const passphrase = PassphraseGenerate() // const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key // const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash // const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) - const emailHash = getEmailHash(email) + // const emailHash = getEmailHash(email) const gradidoID = await newGradidoID() const eventRegister = new EventRegister() const eventRedeemRegister = new EventRedeemRegister() const eventSendConfirmEmail = new EventSendConfirmationEmail() - const dbUser = new DbUser() + + let dbUser = new DbUser() dbUser.gradidoID = gradidoID - dbUser.email = email dbUser.firstName = firstName dbUser.lastName = lastName - dbUser.emailHash = emailHash dbUser.language = language dbUser.publisherId = publisherId dbUser.passphrase = passphrase.join(' ') @@ -443,25 +497,38 @@ export class UserResolver { // loginUser.pubKey = keyPair[0] // loginUser.privKey = encryptedPrivkey - const event = new Event() const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() - await queryRunner.startTransaction('READ UNCOMMITTED') + await queryRunner.startTransaction('REPEATABLE READ') try { - await queryRunner.manager.save(dbUser).catch((error) => { + dbUser = await queryRunner.manager.save(dbUser).catch((error) => { logger.error('Error while saving dbUser', error) throw new Error('error saving user') }) + let emailContact = newEmailContact(email, dbUser.id) + emailContact = await queryRunner.manager.save(emailContact).catch((error) => { + logger.error('Error while saving emailContact', error) + throw new Error('error saving email user contact') + }) + dbUser.emailContact = emailContact + dbUser.emailId = emailContact.id + await queryRunner.manager.save(dbUser).catch((error) => { + logger.error('Error while updating dbUser', error) + throw new Error('error updating user') + }) + + /* const emailOptIn = newEmailOptIn(dbUser.id) await queryRunner.manager.save(emailOptIn).catch((error) => { logger.error('Error while saving emailOptIn', error) throw new Error('error saving email opt in') }) + */ const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace( /{optin}/g, - emailOptIn.verificationCode.toString(), + emailContact.emailVerificationCode.toString(), ).replace(/{code}/g, redeemCode ? '/' + redeemCode : '') // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -476,8 +543,6 @@ export class UserResolver { eventSendConfirmEmail.userId = dbUser.id eventProtocol.writeEvent(event.setEventSendConfirmationEmail(eventSendConfirmEmail)) - /* uncomment this, when you need the activation link on the console */ - // In case EMails are disabled log the activation link for the user if (!emailSent) { logger.debug(`Account confirmation link: ${activationLink}`) } @@ -508,22 +573,29 @@ export class UserResolver { async forgotPassword(@Arg('email') email: string): Promise { logger.info(`forgotPassword(${email})...`) email = email.trim().toLowerCase() - const user = await DbUser.findOne({ email }) + const user = await findUserByEmail(email).catch(() => { + logger.warn(`fail on find UserContact per ${email}`) + }) if (!user) { logger.warn(`no user found with ${email}`) return true } // can be both types: REGISTER and RESET_PASSWORD - let optInCode = await LoginEmailOptIn.findOne({ - userId: user.id, - }) + // let optInCode = await LoginEmailOptIn.findOne({ + // userId: user.id, + // }) + // let optInCode = user.emailContact.emailVerificationCode + const dbUserContact = await checkEmailVerificationCode( + user.emailContact, + OptInType.EMAIL_OPT_IN_RESET_PASSWORD, + ) - optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD) - logger.info(`optInCode for ${email}=${optInCode}`) + // optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD) + logger.info(`optInCode for ${email}=${dbUserContact}`) // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendResetPasswordEmailMailer({ - link: activationLink(optInCode), + link: activationLink(dbUserContact.emailVerificationCode), firstName: user.firstName, lastName: user.lastName, email, @@ -533,7 +605,7 @@ export class UserResolver { /* uncomment this, when you need the activation link on the console */ // In case EMails are disabled log the activation link for the user if (!emailSent) { - logger.debug(`Reset password link: ${activationLink(optInCode)}`) + logger.debug(`Reset password link: ${activationLink(dbUserContact.emailVerificationCode)}`) } logger.info(`forgotPassword(${email}) successful...`) @@ -556,13 +628,22 @@ export class UserResolver { } // Load code + /* const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: code }).catch(() => { logger.error('Could not login with emailVerificationCode') throw new Error('Could not login with emailVerificationCode') }) - logger.debug('optInCode loaded...') + */ + const userContact = await DbUserContact.findOneOrFail( + { emailVerificationCode: code }, + { relations: ['user'] }, + ).catch(() => { + logger.error('Could not login with emailVerificationCode') + throw new Error('Could not login with emailVerificationCode') + }) + logger.debug('userContact loaded...') // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes - if (!isOptInValid(optInCode)) { + if (!isEmailVerificationCodeValid(userContact.updatedAt)) { logger.error( `email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`, ) @@ -570,14 +651,11 @@ export class UserResolver { `email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`, ) } - logger.debug('optInCode is valid...') + logger.debug('EmailVerificationCode is valid...') // load user - const user = await DbUser.findOneOrFail({ id: optInCode.userId }).catch(() => { - logger.error('Could not find corresponding Login User') - throw new Error('Could not find corresponding Login User') - }) - logger.debug('user with optInCode found...') + const user = userContact.user + logger.debug('user with EmailVerificationCode found...') // Generate Passphrase if needed if (!user.passphrase) { @@ -597,10 +675,10 @@ export class UserResolver { logger.debug('Passphrase is valid...') // Activate EMail - user.emailChecked = true + userContact.emailChecked = true // Update Password - const passwordHash = SecretKeyCryptographyCreateKey(user.email, password) // return short and long hash + const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) user.password = passwordHash[0].readBigUInt64LE() // using the shorthash @@ -610,7 +688,7 @@ export class UserResolver { const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() - await queryRunner.startTransaction('READ UNCOMMITTED') + await queryRunner.startTransaction('REPEATABLE READ') const event = new Event() @@ -620,17 +698,21 @@ export class UserResolver { logger.error('error saving user: ' + error) throw new Error('error saving user: ' + error) }) + // Save userContact + await queryRunner.manager.save(userContact).catch((error) => { + logger.error('error saving userContact: ' + error) + throw new Error('error saving userContact: ' + error) + }) await queryRunner.commitTransaction() + logger.info('User and UserContact data written successfully...') const eventActivateAccount = new EventActivateAccount() eventActivateAccount.userId = user.id eventProtocol.writeEvent(event.setEventActivateAccount(eventActivateAccount)) - - logger.info('User data written successfully...') } catch (e) { await queryRunner.rollbackTransaction() - logger.error('Error on writing User data:' + e) + logger.error('Error on writing User and UserContact data:' + e) throw e } finally { await queryRunner.release() @@ -638,11 +720,11 @@ export class UserResolver { // Sign into Klicktipp // TODO do we always signUp the user? How to handle things with old users? - if (optInCode.emailOptInTypeId === OptInType.EMAIL_OPT_IN_REGISTER) { + if (userContact.emailOptInTypeId === OptInType.EMAIL_OPT_IN_REGISTER) { try { - await klicktippSignIn(user.email, user.language, user.firstName, user.lastName) + await klicktippSignIn(userContact.email, user.language, user.firstName, user.lastName) logger.debug( - `klicktippSignIn(${user.email}, ${user.language}, ${user.firstName}, ${user.lastName})`, + `klicktippSignIn(${userContact.email}, ${user.language}, ${user.firstName}, ${user.lastName})`, ) } catch (e) { logger.error('Error subscribe to klicktipp:' + e) @@ -661,10 +743,10 @@ export class UserResolver { @Query(() => Boolean) async queryOptIn(@Arg('optIn') optIn: string): Promise { logger.info(`queryOptIn(${optIn})...`) - const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: optIn }) - logger.debug(`found optInCode=${optInCode}`) + const userContact = await DbUserContact.findOneOrFail({ emailVerificationCode: optIn }) + logger.debug(`found optInCode=${userContact}`) // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes - if (!isOptInValid(optInCode)) { + if (!isEmailVerificationCodeValid(userContact.updatedAt)) { logger.error( `email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`, ) @@ -712,7 +794,10 @@ export class UserResolver { } // TODO: This had some error cases defined - like missing private key. This is no longer checked. - const oldPasswordHash = SecretKeyCryptographyCreateKey(userEntity.email, password) + const oldPasswordHash = SecretKeyCryptographyCreateKey( + userEntity.emailContact.email, + password, + ) if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) { logger.error(`Old password is invalid`) throw new Error(`Old password is invalid`) @@ -720,7 +805,10 @@ export class UserResolver { const privKey = SecretKeyCryptographyDecrypt(userEntity.privKey, oldPasswordHash[1]) logger.debug('oldPassword decrypted...') - const newPasswordHash = SecretKeyCryptographyCreateKey(userEntity.email, passwordNew) // return short and long hash + const newPasswordHash = SecretKeyCryptographyCreateKey( + userEntity.emailContact.email, + passwordNew, + ) // return short and long hash logger.debug('newPasswordHash created...') const encryptedPrivkey = SecretKeyCryptographyEncrypt(privKey, newPasswordHash[1]) logger.debug('PrivateKey encrypted...') @@ -732,7 +820,7 @@ export class UserResolver { const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() - await queryRunner.startTransaction('READ UNCOMMITTED') + await queryRunner.startTransaction('REPEATABLE READ') try { await queryRunner.manager.save(userEntity).catch((error) => { @@ -757,12 +845,8 @@ export class UserResolver { @Query(() => Boolean) async hasElopage(@Ctx() context: Context): Promise { logger.info(`hasElopage()...`) - const userEntity = context.user - if (!userEntity) { - logger.info('missing context.user for EloPage-check') - return false - } - const elopageBuys = hasElopageBuys(userEntity.email) + const userEntity = getUser(context) + const elopageBuys = hasElopageBuys(userEntity.emailContact.email) logger.debug(`has ElopageBuys = ${elopageBuys}`) return elopageBuys } @@ -798,19 +882,58 @@ export class UserResolver { } } +export async function findUserByEmail(email: string): Promise { + const dbUserContact = await DbUserContact.findOneOrFail( + { email: email }, + { withDeleted: true, relations: ['user'] }, + ).catch(() => { + logger.error(`UserContact with email=${email} does not exists`) + throw new Error('No user with this credentials') + }) + const dbUser = dbUserContact.user + dbUser.emailContact = dbUserContact + return dbUser +} + +async function checkEmailExists(email: string): Promise { + const userContact = await DbUserContact.findOne({ email: email }, { withDeleted: true }) + if (userContact) { + return true + } + return false +} + +/* const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => { const timeElapsed = Date.now() - new Date(optIn.updatedAt).getTime() // time is given in minutes return timeElapsed <= duration * 60 * 1000 } - +*/ +const isTimeExpired = (updatedAt: Date, duration: number): boolean => { + const timeElapsed = Date.now() - new Date(updatedAt).getTime() + // time is given in minutes + return timeElapsed <= duration * 60 * 1000 +} +/* const isOptInValid = (optIn: LoginEmailOptIn): boolean => { return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME) } - +*/ +const isEmailVerificationCodeValid = (updatedAt: Date | null): boolean => { + if (updatedAt == null) { + return true + } + return isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_VALID_TIME) +} +/* const canResendOptIn = (optIn: LoginEmailOptIn): boolean => { return !isTimeExpired(optIn, CONFIG.EMAIL_CODE_REQUEST_TIME) } +*/ +const canEmailResend = (updatedAt: Date): boolean => { + return !isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_REQUEST_TIME) +} const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => { if (time > 60) { diff --git a/backend/src/graphql/resolver/util/creations.ts b/backend/src/graphql/resolver/util/creations.ts index ad15ebec6..4f1cec0e0 100644 --- a/backend/src/graphql/resolver/util/creations.ts +++ b/backend/src/graphql/resolver/util/creations.ts @@ -15,14 +15,21 @@ export const validateContribution = ( amount: Decimal, creationDate: Date, ): void => { - logger.trace('isContributionValid', creations, amount, creationDate) + logger.trace('isContributionValid: ', creations, amount, creationDate) const index = getCreationIndex(creationDate.getMonth()) if (index < 0) { + logger.error( + 'No information for available creations with the given creationDate=', + creationDate, + ) throw new Error('No information for available creations for the given date') } if (amount.greaterThan(creations[index].toString())) { + logger.error( + `The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`, + ) throw new Error( `The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`, ) @@ -41,7 +48,7 @@ export const getUserCreations = async ( await queryRunner.connect() const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day' - logger.trace('getUserCreations dateFilter', dateFilter) + logger.trace('getUserCreations dateFilter=', dateFilter) const unionString = includePending ? ` @@ -51,6 +58,7 @@ export const getUserCreations = async ( AND contribution_date >= ${dateFilter} AND confirmed_at IS NULL AND deleted_at IS NULL` : '' + logger.trace('getUserCreations unionString=', unionString) const unionQuery = await queryRunner.manager.query(` SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM @@ -62,6 +70,7 @@ export const getUserCreations = async ( GROUP BY month, userId ORDER BY date DESC `) + logger.trace('getUserCreations unionQuery=', unionQuery) await queryRunner.release() @@ -82,6 +91,7 @@ export const getUserCreations = async ( export const getUserCreation = async (id: number, includePending = true): Promise => { logger.trace('getUserCreation', id, includePending) const creations = await getUserCreations([id], includePending) + logger.trace('getUserCreation creations=', creations) return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE } diff --git a/backend/src/seeds/factory/contributionLink.ts b/backend/src/seeds/factory/contributionLink.ts index 5c83b6ad3..d8f31d585 100644 --- a/backend/src/seeds/factory/contributionLink.ts +++ b/backend/src/seeds/factory/contributionLink.ts @@ -11,7 +11,11 @@ export const contributionLinkFactory = async ( const { mutate, query } = client // login as admin - await query({ query: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const user = await query({ + query: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) const variables = { amount: contributionLink.amount, diff --git a/backend/src/seeds/factory/creation.ts b/backend/src/seeds/factory/creation.ts index d3f0f78ca..606bac1f7 100644 --- a/backend/src/seeds/factory/creation.ts +++ b/backend/src/seeds/factory/creation.ts @@ -1,13 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { backendLogger as logger } from '@/server/logger' import { adminCreateContribution, confirmContribution } from '@/seeds/graphql/mutations' import { login } from '@/seeds/graphql/queries' import { CreationInterface } from '@/seeds/creation/CreationInterface' import { ApolloServerTestClient } from 'apollo-server-testing' -import { User } from '@entity/User' import { Transaction } from '@entity/Transaction' import { Contribution } from '@entity/Contribution' +import { findUserByEmail } from '@/graphql/resolver/UserResolver' // import CONFIG from '@/config/index' export const nMonthsBefore = (date: Date, months = 1): string => { @@ -19,29 +20,41 @@ export const creationFactory = async ( creation: CreationInterface, ): Promise => { const { mutate, query } = client - + logger.trace('creationFactory...') await query({ query: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } }) - + logger.trace('creationFactory... after login') // TODO it would be nice to have this mutation return the id await mutate({ mutation: adminCreateContribution, variables: { ...creation } }) + logger.trace('creationFactory... after adminCreateContribution') - const user = await User.findOneOrFail({ where: { email: creation.email } }) + const user = await findUserByEmail(creation.email) // userContact.user const pendingCreation = await Contribution.findOneOrFail({ where: { userId: user.id, amount: creation.amount }, order: { createdAt: 'DESC' }, }) - + logger.trace( + 'creationFactory... after Contribution.findOneOrFail pendingCreation=', + pendingCreation, + ) if (creation.confirmed) { + logger.trace('creationFactory... creation.confirmed=', creation.confirmed) await mutate({ mutation: confirmContribution, variables: { id: pendingCreation.id } }) - + logger.trace('creationFactory... after confirmContribution') const confirmedCreation = await Contribution.findOneOrFail({ id: pendingCreation.id }) + logger.trace( + 'creationFactory... after Contribution.findOneOrFail confirmedCreation=', + confirmedCreation, + ) if (creation.moveCreationDate) { + logger.trace('creationFactory... creation.moveCreationDate=', creation.moveCreationDate) const transaction = await Transaction.findOneOrFail({ where: { userId: user.id, creationDate: new Date(creation.creationDate) }, order: { balanceDate: 'DESC' }, }) + logger.trace('creationFactory... after Transaction.findOneOrFail transaction=', transaction) + if (transaction.decay.equals(0) && transaction.creationDate) { confirmedCreation.contributionDate = new Date( nMonthsBefore(transaction.creationDate, creation.moveCreationDate), @@ -52,11 +65,17 @@ export const creationFactory = async ( transaction.balanceDate = new Date( nMonthsBefore(transaction.balanceDate, creation.moveCreationDate), ) + logger.trace('creationFactory... before transaction.save transaction=', transaction) await transaction.save() + logger.trace( + 'creationFactory... before confirmedCreation.save confirmedCreation=', + confirmedCreation, + ) await confirmedCreation.save() } } } else { + logger.trace('creationFactory... pendingCreation=', pendingCreation) return pendingCreation } } diff --git a/backend/src/seeds/factory/user.ts b/backend/src/seeds/factory/user.ts index d94f94b3c..d566275db 100644 --- a/backend/src/seeds/factory/user.ts +++ b/backend/src/seeds/factory/user.ts @@ -1,6 +1,5 @@ import { createUser, setPassword } from '@/seeds/graphql/mutations' import { User } from '@entity/User' -import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { UserInterface } from '@/seeds/users/UserInterface' import { ApolloServerTestClient } from 'apollo-server-testing' @@ -15,17 +14,23 @@ export const userFactory = async ( createUser: { id }, }, } = await mutate({ mutation: createUser, variables: user }) + // console.log('creatUser:', { id }, { user }) + // get user from database + let dbUser = await User.findOneOrFail({ id }, { relations: ['emailContact'] }) + // console.log('dbUser:', dbUser) + + const emailContact = dbUser.emailContact + // console.log('emailContact:', emailContact) if (user.emailChecked) { - const optin = await LoginEmailOptIn.findOneOrFail({ userId: id }) await mutate({ mutation: setPassword, - variables: { password: 'Aa12345_', code: optin.verificationCode }, + variables: { password: 'Aa12345_', code: emailContact.emailVerificationCode }, }) } - // get user from database - const dbUser = await User.findOneOrFail({ id }) + // get last changes of user from database + dbUser = await User.findOneOrFail({ id }) if (user.createdAt || user.deletedAt || user.isAdmin) { if (user.createdAt) dbUser.createdAt = user.createdAt @@ -34,5 +39,8 @@ export const userFactory = async ( await dbUser.save() } + // get last changes of user from database + // dbUser = await User.findOneOrFail({ id }, { withDeleted: true }) + return dbUser } diff --git a/backend/src/seeds/index.ts b/backend/src/seeds/index.ts index 8e9a4e2d8..c5a55cb84 100644 --- a/backend/src/seeds/index.ts +++ b/backend/src/seeds/index.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { backendLogger as logger } from '@/server/logger' import createServer from '../server/createServer' import { createTestClient } from 'apollo-server-testing' @@ -50,11 +51,14 @@ const run = async () => { const seedClient = createTestClient(server.apollo) const { con } = server await cleanDB() + logger.info('##seed## clean database successful...') // seed the standard users for (let i = 0; i < users.length; i++) { - await userFactory(seedClient, users[i]) + const dbUser = await userFactory(seedClient, users[i]) + logger.info(`##seed## seed standard users[ ${i} ]= ${JSON.stringify(dbUser, null, 2)}`) } + logger.info('##seed## seeding all standard users successful...') // seed 100 random users for (let i = 0; i < 100; i++) { @@ -64,7 +68,9 @@ const run = async () => { email: internet.email(), language: datatype.boolean() ? 'en' : 'de', }) + logger.info(`##seed## seed ${i}. random user`) } + logger.info('##seed## seeding all random users successful...') // create GDD for (let i = 0; i < creations.length; i++) { @@ -73,16 +79,19 @@ const run = async () => { // eslint-disable-next-line no-empty while (new Date().getTime() < now + 1000) {} // we have to wait a little! quick fix for account sum problem of bob@baumeister.de, (see https://github.com/gradido/gradido/issues/1886) } + logger.info('##seed## seeding all creations successful...') // create Transaction Links for (let i = 0; i < transactionLinks.length; i++) { await transactionLinkFactory(seedClient, transactionLinks[i]) } + logger.info('##seed## seeding all transactionLinks successful...') // create Contribution Links for (let i = 0; i < contributionLinks.length; i++) { await contributionLinkFactory(seedClient, contributionLinks[i]) } + logger.info('##seed## seeding all contributionLinks successful...') await con.close() } diff --git a/backend/src/typeorm/repository/User.ts b/backend/src/typeorm/repository/User.ts index 01f61dcbc..8b3e29859 100644 --- a/backend/src/typeorm/repository/User.ts +++ b/backend/src/typeorm/repository/User.ts @@ -1,28 +1,39 @@ -import { Brackets, EntityRepository, ObjectLiteral, Repository } from '@dbTools/typeorm' -import { User } from '@entity/User' +import SearchUsersFilters from '@/graphql/arg/SearchUsersFilters' +import { Brackets, EntityRepository, IsNull, Not, Repository } from '@dbTools/typeorm' +import { User as DbUser } from '@entity/User' -@EntityRepository(User) -export class UserRepository extends Repository { - async findByPubkeyHex(pubkeyHex: string): Promise { - return this.createQueryBuilder('user') +@EntityRepository(DbUser) +export class UserRepository extends Repository { + async findByPubkeyHex(pubkeyHex: string): Promise { + const dbUser = await this.createQueryBuilder('user') + .leftJoinAndSelect('user.emailContact', 'emailContact') .where('hex(user.pubKey) = :pubkeyHex', { pubkeyHex }) .getOneOrFail() + /* + const dbUser = await this.findOneOrFail(`hex(user.pubKey) = { pubkeyHex }`) + const emailContact = await this.query( + `SELECT * from user_contacts where id = { dbUser.emailId }`, + ) + dbUser.emailContact = emailContact + */ + return dbUser } async findBySearchCriteriaPagedFiltered( select: string[], searchCriteria: string, - filterCriteria: ObjectLiteral[], + filters: SearchUsersFilters, currentPage: number, pageSize: number, - ): Promise<[User[], number]> { - const query = await this.createQueryBuilder('user') + ): Promise<[DbUser[], number]> { + const query = this.createQueryBuilder('user') .select(select) + .leftJoinAndSelect('user.emailContact', 'emailContact') .withDeleted() .where( new Brackets((qb) => { qb.where( - 'user.firstName like :name or user.lastName like :lastName or user.email like :email', + 'user.firstName like :name or user.lastName like :lastName or emailContact.email like :email', { name: `%${searchCriteria}%`, lastName: `%${searchCriteria}%`, @@ -31,9 +42,23 @@ export class UserRepository extends Repository { ) }), ) + /* filterCriteria.forEach((filter) => { query.andWhere(filter) }) + */ + if (filters) { + if (filters.byActivated !== null) { + query.andWhere('emailContact.emailChecked = :value', { value: filters.byActivated }) + // filterCriteria.push({ 'emailContact.emailChecked': filters.byActivated }) + } + + if (filters.byDeleted !== null) { + // filterCriteria.push({ deletedAt: filters.byDeleted ? Not(IsNull()) : IsNull() }) + query.andWhere({ deletedAt: filters.byDeleted ? Not(IsNull()) : IsNull() }) + } + } + return query .take(pageSize) .skip((currentPage - 1) * pageSize) diff --git a/backend/src/util/communityUser.ts b/backend/src/util/communityUser.ts index d850c5382..e885b7043 100644 --- a/backend/src/util/communityUser.ts +++ b/backend/src/util/communityUser.ts @@ -2,22 +2,26 @@ import { SaveOptions, RemoveOptions } from '@dbTools/typeorm' import { User as dbUser } from '@entity/User' +import { UserContact } from '@entity/UserContact' +// import { UserContact as EmailContact } from '@entity/UserContact' import { User } from '@model/User' const communityDbUser: dbUser = { id: -1, gradidoID: '11111111-2222-4333-4444-55555555', alias: '', - email: 'support@gradido.net', + // email: 'support@gradido.net', + emailContact: new UserContact(), + emailId: -1, firstName: 'Gradido', lastName: 'Akademie', pubKey: Buffer.from(''), privKey: Buffer.from(''), deletedAt: null, password: BigInt(0), - emailHash: Buffer.from(''), + // emailHash: Buffer.from(''), createdAt: new Date(), - emailChecked: false, + // emailChecked: false, language: '', isAdmin: null, publisherId: 0, diff --git a/backend/src/util/klicktipp.ts b/backend/src/util/klicktipp.ts index c8f83acc3..0432f196e 100644 --- a/backend/src/util/klicktipp.ts +++ b/backend/src/util/klicktipp.ts @@ -7,16 +7,16 @@ export async function retrieveNotRegisteredEmails(): Promise { if (!con) { throw new Error('No connection to database') } - const users = await User.find() + const users = await User.find({ relations: ['emailContact'] }) const notRegisteredUser = [] for (let i = 0; i < users.length; i++) { const user = users[i] try { - await getKlickTippUser(user.email) + await getKlickTippUser(user.emailContact.email) } catch (err) { - notRegisteredUser.push(user.email) + notRegisteredUser.push(user.emailContact.email) // eslint-disable-next-line no-console - console.log(`${user.email}`) + console.log(`${user.emailContact.email}`) } } await con.close() diff --git a/backend/src/webhook/elopage.ts b/backend/src/webhook/elopage.ts index d5eaef521..87af4088c 100644 --- a/backend/src/webhook/elopage.ts +++ b/backend/src/webhook/elopage.ts @@ -29,7 +29,7 @@ import { LoginElopageBuys } from '@entity/LoginElopageBuys' import { UserResolver } from '@/graphql/resolver/UserResolver' -import { User as dbUser } from '@entity/User' +import { UserContact as dbUserContact } from '@entity/UserContact' export const elopageWebhook = async (req: any, res: any): Promise => { // eslint-disable-next-line no-console @@ -127,7 +127,8 @@ export const elopageWebhook = async (req: any, res: any): Promise => { } // Do we already have such a user? - if ((await dbUser.count({ email })) !== 0) { + // if ((await dbUser.count({ email })) !== 0) { + if ((await dbUserContact.count({ email })) !== 0) { // eslint-disable-next-line no-console console.log(`Did not create User - already exists with email: ${email}`) return diff --git a/database/entity/0049-add_user_contacts_table/User.ts b/database/entity/0049-add_user_contacts_table/User.ts new file mode 100644 index 000000000..abe40df54 --- /dev/null +++ b/database/entity/0049-add_user_contacts_table/User.ts @@ -0,0 +1,126 @@ +import { + BaseEntity, + Entity, + PrimaryGeneratedColumn, + Column, + DeleteDateColumn, + OneToMany, + JoinColumn, + OneToOne, +} from 'typeorm' +import { Contribution } from '../Contribution' +import { ContributionMessage } from '../ContributionMessage' +import { UserContact } from '../UserContact' + +@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) +export class User extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ + name: 'gradido_id', + length: 36, + nullable: false, + collation: 'utf8mb4_unicode_ci', + }) + gradidoID: string + + @Column({ + name: 'alias', + length: 20, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + alias: string + + @Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true }) + pubKey: Buffer + + @Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true }) + privKey: Buffer + + /* + @Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' }) + email: string + */ + @OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user) + @JoinColumn({ name: 'email_id' }) + emailContact: UserContact + + @Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null }) + emailId: number | null + + @Column({ + name: 'first_name', + length: 255, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + firstName: string + + @Column({ + name: 'last_name', + length: 255, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + lastName: string + + @DeleteDateColumn() + deletedAt: Date | null + + @Column({ type: 'bigint', default: 0, unsigned: true }) + password: BigInt + + @Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP', nullable: false }) + createdAt: Date + /* + @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) + emailChecked: boolean + */ + + @Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false }) + language: string + + @Column({ name: 'is_admin', type: 'datetime', nullable: true, default: null }) + isAdmin: Date | null + + @Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null }) + referrerId?: number | null + + @Column({ + name: 'contribution_link_id', + type: 'int', + unsigned: true, + nullable: true, + default: null, + }) + contributionLinkId?: number | null + + @Column({ name: 'publisher_id', default: 0 }) + publisherId: number + + @Column({ + type: 'text', + name: 'passphrase', + collation: 'utf8mb4_unicode_ci', + nullable: true, + default: null, + }) + passphrase: string + + @OneToMany(() => Contribution, (contribution) => contribution.user) + @JoinColumn({ name: 'user_id' }) + contributions?: Contribution[] + + @OneToMany(() => ContributionMessage, (message) => message.user) + @JoinColumn({ name: 'user_id' }) + messages?: ContributionMessage[] + + @OneToMany(() => UserContact, (userContact: UserContact) => userContact.user) + @JoinColumn({ name: 'user_id' }) + userContacts?: UserContact[] +} diff --git a/database/entity/0049-add_user_contacts_table/UserContact.ts b/database/entity/0049-add_user_contacts_table/UserContact.ts new file mode 100644 index 000000000..97b12d4cd --- /dev/null +++ b/database/entity/0049-add_user_contacts_table/UserContact.ts @@ -0,0 +1,60 @@ +import { + BaseEntity, + Entity, + PrimaryGeneratedColumn, + Column, + DeleteDateColumn, + OneToOne, +} from 'typeorm' +import { User } from './User' + +@Entity('user_contacts', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) +export class UserContact extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ + name: 'type', + length: 100, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + type: string + + @OneToOne(() => User, (user) => user.emailContact) + user: User + + @Column({ name: 'user_id', type: 'int', unsigned: true, nullable: false }) + userId: number + + @Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' }) + email: string + + @Column({ name: 'email_verification_code', type: 'bigint', unsigned: true, unique: true }) + emailVerificationCode: BigInt + + @Column({ name: 'email_opt_in_type_id' }) + emailOptInTypeId: number + + @Column({ name: 'email_resend_count' }) + emailResendCount: number + + // @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true }) + // emailHash: Buffer + + @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) + emailChecked: boolean + + @Column({ length: 255, unique: false, nullable: true, collation: 'utf8mb4_unicode_ci' }) + phone: string + + @Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false }) + createdAt: Date + + @Column({ name: 'updated_at', nullable: true, default: null, type: 'datetime' }) + updatedAt: Date | null + + @DeleteDateColumn({ name: 'deleted_at', nullable: true }) + deletedAt: Date | null +} diff --git a/database/entity/User.ts b/database/entity/User.ts index 7d15bf559..d073f428a 100644 --- a/database/entity/User.ts +++ b/database/entity/User.ts @@ -1 +1 @@ -export { User } from './0047-messages_tables/User' +export { User } from './0049-add_user_contacts_table/User' diff --git a/database/entity/UserContact.ts b/database/entity/UserContact.ts new file mode 100644 index 000000000..a368bb7ca --- /dev/null +++ b/database/entity/UserContact.ts @@ -0,0 +1 @@ +export { UserContact } from './0049-add_user_contacts_table/UserContact' diff --git a/database/entity/index.ts b/database/entity/index.ts index abd31bfb9..a82ef561c 100644 --- a/database/entity/index.ts +++ b/database/entity/index.ts @@ -5,6 +5,7 @@ import { Migration } from './Migration' import { Transaction } from './Transaction' import { TransactionLink } from './TransactionLink' import { User } from './User' +import { UserContact } from './UserContact' import { Contribution } from './Contribution' import { EventProtocol } from './EventProtocol' import { ContributionMessage } from './ContributionMessage' @@ -20,4 +21,5 @@ export const entities = [ User, EventProtocol, ContributionMessage, + UserContact, ] diff --git a/database/migrations/0049-add_user_contacts_table.ts b/database/migrations/0049-add_user_contacts_table.ts new file mode 100644 index 000000000..c3b89ed88 --- /dev/null +++ b/database/migrations/0049-add_user_contacts_table.ts @@ -0,0 +1,129 @@ +/* MIGRATION TO ADD GRADIDO_ID + * + * This migration adds new columns to the table `users` and creates the + * new table `user_contacts` + */ + +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(` + CREATE TABLE IF NOT EXISTS \`user_contacts\` ( + \`id\` int(10) unsigned NOT NULL AUTO_INCREMENT, + \`type\` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + \`user_id\` int(10) unsigned NOT NULL, + \`email\` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL UNIQUE, + \`email_verification_code\` bigint(20) unsigned NOT NULL UNIQUE, + \`email_opt_in_type_id\` int NOT NULL, + \`email_resend_count\` int DEFAULT '0', + \`email_checked\` tinyint(4) NOT NULL DEFAULT 0, + \`phone\` varchar(255) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, + \`created_at\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + \`updated_at\` datetime(3) NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP(3), + \`deleted_at\` datetime(3) NULL DEFAULT NULL, + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`) + + await queryFn('ALTER TABLE `users` ADD COLUMN `email_id` int(10) NULL AFTER `email`;') + // define datetime column with a precision of 3 milliseconds + await queryFn( + 'ALTER TABLE `users` MODIFY COLUMN `created` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) AFTER `email_hash`;', + ) + // define datetime column with a precision of 3 milliseconds + await queryFn( + 'ALTER TABLE `users` MODIFY COLUMN `deletedAt` datetime(3) NULL DEFAULT NULL AFTER `last_name`;', + ) + // define datetime column with a precision of 3 milliseconds + await queryFn( + 'ALTER TABLE `users` MODIFY COLUMN `is_admin` datetime(3) NULL DEFAULT NULL AFTER `language`;', + ) + + // merge values from login_email_opt_in table with users.email in new user_contacts table + await queryFn(` + INSERT INTO user_contacts + (type, user_id, email, email_verification_code, email_opt_in_type_id, email_resend_count, email_checked, created_at, updated_at, deleted_at) + SELECT + 'EMAIL', + u.id as user_id, + u.email, + e.verification_code as email_verification_code, + e.email_opt_in_type_id, + e.resend_count as email_resend_count, + u.email_checked, + e.created as created_at, + e.updated as updated_at, + u.deletedAt as deleted_at\ + FROM + users as u, + login_email_opt_in as e + WHERE + u.id = e.user_id AND + e.id in ( + WITH opt_in AS ( + SELECT + le.id, le.user_id, le.created, le.updated, ROW_NUMBER() OVER (PARTITION BY le.user_id ORDER BY le.created DESC) AS row_num + FROM + login_email_opt_in as le + ) + SELECT + opt_in.id + FROM + opt_in + WHERE + row_num = 1);`) + /* + // SELECT + // le.id + // FROM + // login_email_opt_in as le + // WHERE + // le.user_id = u.id + // ORDER BY + // le.updated DESC, le.created DESC LIMIT 1);`) + */ + + // insert in users table the email_id of the new created email-contacts + const contacts = await queryFn(`SELECT c.id, c.user_id FROM user_contacts as c`) + for (const id in contacts) { + const contact = contacts[id] + await queryFn( + `UPDATE users as u SET u.email_id = "${contact.id}" WHERE u.id = "${contact.user_id}"`, + ) + } + // these steps comes after verification and test + await queryFn('ALTER TABLE users DROP COLUMN email;') + await queryFn('ALTER TABLE users DROP COLUMN email_checked;') +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + // this step comes after verification and test + await queryFn('ALTER TABLE users ADD COLUMN email varchar(255) NULL AFTER privkey;') + await queryFn( + 'ALTER TABLE users ADD COLUMN email_checked tinyint(4) NOT NULL DEFAULT 0 AFTER email;', + ) + await queryFn( + 'ALTER TABLE `users` MODIFY COLUMN `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `email_hash`;', + ) + await queryFn( + 'ALTER TABLE `users` MODIFY COLUMN `deletedAt` datetime NULL DEFAULT NULL AFTER `last_name`;', + ) + await queryFn( + 'ALTER TABLE `users` MODIFY COLUMN `is_admin` datetime NULL DEFAULT NULL AFTER `language`;', + ) + + // reconstruct the previous email back from contacts to users table + const contacts = await queryFn(`SELECT c.id, c.email, c.user_id FROM user_contacts as c`) + for (const id in contacts) { + const contact = contacts[id] + await queryFn( + `UPDATE users SET email = "${contact.email}" WHERE id = "${contact.user_id}" and email_id = "${contact.id}"`, + ) + } + await queryFn('ALTER TABLE users MODIFY COLUMN email varchar(255) NOT NULL UNIQUE;') + + // write downgrade logic as parameter of queryFn + await queryFn(`DROP TABLE IF EXISTS user_contacts;`) + + await queryFn('ALTER TABLE users DROP COLUMN email_id;') +} diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 2af3c41ee..fe2f68a8d 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,122 +1,122 @@ -version: "3.4" - -services: - - ######################################################## - # FRONTEND ############################################# - ######################################################## - frontend: - # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there - image: gradido/frontend:local-development - build: - target: development - environment: - - NODE_ENV="development" - # - DEBUG=true - volumes: - # This makes sure the docker container has its own node modules. - # Therefore it is possible to have a different node version on the host machine - - frontend_node_modules:/app/node_modules - # bind the local folder to the docker to allow live reload - - ./frontend:/app - - ######################################################## - # ADMIN INTERFACE ###################################### - ######################################################## - admin: - # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there - image: gradido/admin:local-development - build: - target: development - environment: - - NODE_ENV="development" - # - DEBUG=true - volumes: - # This makes sure the docker container has its own node modules. - # Therefore it is possible to have a different node version on the host machine - - admin_node_modules:/app/node_modules - # bind the local folder to the docker to allow live reload - - ./admin:/app - - ######################################################## - # BACKEND ############################################## - ######################################################## - backend: - # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there - image: gradido/backend:local-development - build: - target: development - networks: - - external-net - - internal-net - environment: - - NODE_ENV="development" - volumes: - # This makes sure the docker container has its own node modules. - # Therefore it is possible to have a different node version on the host machine - - backend_node_modules:/app/node_modules - - backend_database_node_modules:/database/node_modules - - backend_database_build:/database/build - # bind the local folder to the docker to allow live reload - - ./backend:/app - - ./database:/database - - ######################################################## - # DATABASE ############################################## - ######################################################## - database: - # we always run on production here since else the service lingers - # feel free to change this behaviour if it seems useful - # Due to problems with the volume caching the built files - # we changed this to test build. This keeps the service running. - # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there - image: gradido/database:local-test_up - build: - target: test_up - environment: - - NODE_ENV="development" - volumes: - # This makes sure the docker container has its own node modules. - # Therefore it is possible to have a different node version on the host machine - - database_node_modules:/app/node_modules - - database_build:/app/build - # bind the local folder to the docker to allow live reload - - ./database:/app - - ######################################################### - ## MARIADB ############################################## - ######################################################### - mariadb: - networks: - - internal-net - - external-net - - ######################################################### - ## NGINX ################################################ - ######################################################### - # nginx: - - ######################################################### - ## PHPMYADMIN ########################################### - ######################################################### - phpmyadmin: - image: phpmyadmin - environment: - - PMA_ARBITRARY=1 - #restart: always - ports: - - 8074:80 - networks: - - internal-net - - external-net - volumes: - - /sessions - -volumes: - frontend_node_modules: - admin_node_modules: - backend_node_modules: - backend_database_node_modules: - backend_database_build: - database_node_modules: - database_build: +version: "3.4" + +services: + + ######################################################## + # FRONTEND ############################################# + ######################################################## + frontend: + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: gradido/frontend:local-development + build: + target: development + environment: + - NODE_ENV="development" + # - DEBUG=true + volumes: + # This makes sure the docker container has its own node modules. + # Therefore it is possible to have a different node version on the host machine + - frontend_node_modules:/app/node_modules + # bind the local folder to the docker to allow live reload + - ./frontend:/app + + ######################################################## + # ADMIN INTERFACE ###################################### + ######################################################## + admin: + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: gradido/admin:local-development + build: + target: development + environment: + - NODE_ENV="development" + # - DEBUG=true + volumes: + # This makes sure the docker container has its own node modules. + # Therefore it is possible to have a different node version on the host machine + - admin_node_modules:/app/node_modules + # bind the local folder to the docker to allow live reload + - ./admin:/app + + ######################################################## + # BACKEND ############################################## + ######################################################## + backend: + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: gradido/backend:local-development + build: + target: development + networks: + - external-net + - internal-net + environment: + - NODE_ENV="development" + volumes: + # This makes sure the docker container has its own node modules. + # Therefore it is possible to have a different node version on the host machine + - backend_node_modules:/app/node_modules + - backend_database_node_modules:/database/node_modules + - backend_database_build:/database/build + # bind the local folder to the docker to allow live reload + - ./backend:/app + - ./database:/database + + ######################################################## + # DATABASE ############################################## + ######################################################## + database: + # we always run on production here since else the service lingers + # feel free to change this behaviour if it seems useful + # Due to problems with the volume caching the built files + # we changed this to test build. This keeps the service running. + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: gradido/database:local-test_up + build: + target: test_up + environment: + - NODE_ENV="development" + volumes: + # This makes sure the docker container has its own node modules. + # Therefore it is possible to have a different node version on the host machine + - database_node_modules:/app/node_modules + - database_build:/app/build + # bind the local folder to the docker to allow live reload + - ./database:/app + + ######################################################### + ## MARIADB ############################################## + ######################################################### + mariadb: + networks: + - internal-net + - external-net + + ######################################################### + ## NGINX ################################################ + ######################################################### + # nginx: + + ######################################################### + ## PHPMYADMIN ########################################### + ######################################################### + phpmyadmin: + image: phpmyadmin + environment: + - PMA_ARBITRARY=1 + #restart: always + ports: + - 8074:80 + networks: + - internal-net + - external-net + volumes: + - /sessions + +volumes: + frontend_node_modules: + admin_node_modules: + backend_node_modules: + backend_database_node_modules: + backend_database_build: + database_node_modules: + database_build: diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 7db318176..79ee46906 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,61 +1,62 @@ -version: "3.4" - -services: - - ######################################################## - # BACKEND ############################################## - ######################################################## - backend: - # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there - image: gradido/backend:test - build: - target: test - networks: - - external-net - - internal-net - environment: - - NODE_ENV="test" - - DB_HOST=mariadb - - ######################################################## - # DATABASE ############################################# - ######################################################## - database: - build: - context: ./database - target: test_up - # restart: always # this is very dangerous, but worth a test for the delayed mariadb startup at first run - - ######################################################### - ## MARIADB ############################################## - ######################################################### - mariadb: - networks: - - internal-net - - external-net - volumes: - - db_test_vol:/var/lib/mysql - - ######################################################### - ## PHPMYADMIN ########################################### - ######################################################### - phpmyadmin: - image: phpmyadmin - environment: - - PMA_ARBITRARY=1 - #restart: always - ports: - - 8074:80 - networks: - - internal-net - - external-net - volumes: - - /sessions - -networks: - external-net: - internal-net: - internal: true - -volumes: - db_test_vol: +version: "3.4" + +services: + + ######################################################## + # BACKEND ############################################## + ######################################################## + backend: + # name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there + image: gradido/backend:test + build: + target: test + networks: + - external-net + - internal-net + environment: + - NODE_ENV="test" + - DB_HOST=mariadb + + ######################################################## + # DATABASE ############################################# + ######################################################## + database: + build: + context: ./database + target: test_up + # restart: always # this is very dangerous, but worth a test for the delayed mariadb startup at first run + + ######################################################### + ## MARIADB ############################################## + ######################################################### + mariadb: + networks: + - internal-net + - external-net + volumes: + - db_test_vol:/var/lib/mysql + + ######################################################### + ## PHPMYADMIN ########################################### + ######################################################### + phpmyadmin: + image: phpmyadmin + environment: + - PMA_ARBITRARY=1 + #restart: always + ports: + - 8074:80 + networks: + - internal-net + - external-net + volumes: + - /sessions + +networks: + external-net: + internal-net: + internal: true + +volumes: + db_test_vol: + diff --git a/docu/Concepts/TechnicalRequirements/UC_Introduction_of_Gradido-ID.md b/docu/Concepts/TechnicalRequirements/UC_Introduction_of_Gradido-ID.md index e3c0ac2d7..9d607ba97 100644 --- a/docu/Concepts/TechnicalRequirements/UC_Introduction_of_Gradido-ID.md +++ b/docu/Concepts/TechnicalRequirements/UC_Introduction_of_Gradido-ID.md @@ -2,7 +2,7 @@ ## Motivation -To introduce the Gradido-ID base on the requirement to identify an user account per technical key instead of using an email-address. Such a technical key ensures an exact identification of an user account without giving detailed information for possible missusage. +The introduction of the Gradido-ID base on the requirement to identify an user account per technical key instead of using an email-address. Such a technical key ensures an exact identification of an user account without giving detailed information for possible missusage. Additionally the Gradido-ID allows to administrade any user account data like changing the email address or define several email addresses without any side effects on the identification of the user account. @@ -22,12 +22,12 @@ The second step is to decribe all concerning business logic processes, which hav The entity users has to be changed by adding the following columns. -| Column | Type | Description | -| ------------------------ | ------ | -------------------------------------------------------------------------------------- | -| gradidoID | String | technical unique key of the user as UUID (version 4) | -| alias | String | a business unique key of the user | -| passphraseEncryptionType | int | defines the type of encrypting the passphrase: 1 = email (default), 2 = gradidoID, ... | -| emailID | int | technical foreign key to the new entity Contact | +| Column | Type | Description | +| ------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------- | +| gradidoID | String | technical unique key of the user as UUID (version 4) | +| alias | String | a business unique key of the user | +| passphraseEncryptionType | int | defines the type of encrypting the passphrase: 1 = email (default), 2 = gradidoID, ... | +| emailID | int | technical foreign key to the entry with type Email and contactChannel=maincontact of the new entity UserContacts | ##### Email vs emailID @@ -39,14 +39,21 @@ The preferred and proper solution will be to add a new column `Users.emailId `as A new entity `UserContacts `is introduced to store several contacts of different types like email, telephone or other kinds of contact addresses. -| Column | Type | Description | -| --------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| id | int | the technical key of a contact entity | -| type | int | Defines the type of contact entry as enum: Email, Phone, etc | -| usersID | int | Defines the foreign key to the `Users` table | -| email | String | defines the address of a contact entry of type Email | -| phone | String | defines the address of a contact entry of type Phone | -| contactChannels | String | define the contact channel as comma separated list for which this entry is confirmed by the user e.g. main contact (default), infomail, contracting, advertisings, ... | +| Column | Type | Description | +| --------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| id | int | the technical key of a contact entity | +| type | int | Defines the type of contact entry as enum: Email, Phone, etc | +| userID | int | Defines the foreign key to the `Users` table | +| email | String | defines the address of a contact entry of type Email | +| emailVerificationCode | unsinged bigint(20) | unique code to verify email or password reset | +| emailOptInType | int | REGISTER=1, RESET_PASSWORD=2 | +| emailResendCount | int | counter how often the email was resend | +| emailChecked | boolean | flag if email is verified and confirmed | +| createdAt | DateTime | point of time the Contact was created | +| updatedAt | DateTime | point of time the Contact was updated | +| deletedAt | DateTime | point of time the Contact was soft deleted | +| phone | String | defines the address of a contact entry of type Phone | +| contactChannels | String | define the contact channel as comma separated list for which this entry is confirmed by the user e.g. main contact (default), infomail, contracting, advertisings, ... | ### Database-Migration @@ -58,18 +65,24 @@ In a one-time migration create for each entry of the `Users `tabel an unique UUI #### Primary Email Contact -In a one-time migration read for each entry of the `Users `table the `Users.id` and `Users.email` and create for it a new entry in the `UsersContact `table, by initializing the contact-values with: +In a one-time migration read for each entry of the `Users `table the `Users.id` and `Users.email`, select from the table `login_email_opt_in` the entry with the `login_email_opt_in.user_id` = `Users.id` and create a new entry in the `UsersContact `table, by initializing the contact-values with: * id = new technical key * type = Enum-Email * userID = `Users.id` * email = `Users.email` +* emailVerifyCode = `login_email_opt_in.verification_code` +* emailOptInType = `login_email_opt_in.email_opt_in_type_id` +* emailResendCount = `login_email_opt_in.resent_count` +* emailChecked = `Users.emailChecked` +* createdAt = `login_email_opt_in.created_at` +* updatedAt = `login_email_opt_in.updated_at` * phone = null * usedChannel = Enum-"main contact" and update the `Users `entry with `Users.emailId = UsersContact.Id` and `Users.passphraseEncryptionType = 1` -After this one-time migration the column `Users.email` can be deleted. +After this one-time migration and a verification, which ensures that all data are migrated, then the columns `Users.email`, `Users.emailChecked`, `Users.emailHash` and the table `login_email_opt_in` can be deleted. ### Adaption of BusinessLogic @@ -109,7 +122,7 @@ The logic of change password has to be adapted by * read the users email address from the `UsersContact `table * give the email address as input for the password decryption of the existing password - * use the `Users.userID` as input for the password encryption fo the new password + * use the `Users.userID` as input for the password encryption for the new password * change the `Users.passphraseEnrycptionType` to the new value =2 * if the `Users.passphraseEncryptionType` = 2, then @@ -129,11 +142,17 @@ A new logic has to be introduced to search the user identity per different input A new mapping logic will be necessary to allow using unmigrated APIs like GDT-servers api. So it must be possible to give this identity-mapping logic the following input to get the respective output: * email -> userID +* email -> gradidoID * email -> alias +* userID -> gradidoID * userID -> email * userID -> alias +* alias -> gradidoID * alias -> email * alias -> userID +* gradidoID -> email +* gradidoID -> userID +* gradidoID -> alias #### GDT-Access diff --git a/package.json b/package.json index 8bc24d402..d0b45a8c7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "release": "scripts/release.sh" }, "dependencies": { - "auto-changelog": "^2.4.0" + "auto-changelog": "^2.4.0", + "uuid": "^8.3.2" } } diff --git a/yarn.lock b/yarn.lock index d9c16e6f7..2c8f9b681 100644 --- a/yarn.lock +++ b/yarn.lock @@ -81,6 +81,11 @@ uglify-js@^3.1.4: resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.1.tgz#2749d4b8b5b7d67460b4a418023ff73c3fefa60a" integrity sha512-EWhx3fHy3M9JbaeTnO+rEqzCe1wtyQClv6q3YWq0voOj4E+bMZBErVS1GAHPDiRGONYq34M1/d8KuQMgvi6Gjw== +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"