Merge branch 'master' into 2290-New-Design

This commit is contained in:
Alexander Friedland 2022-12-16 08:02:06 +01:00 committed by GitHub
commit 8b965ce7d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 4197 additions and 6136 deletions

View File

@ -4,8 +4,30 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [1.16.0](https://github.com/gradido/gradido/compare/1.15.0...1.16.0)
- refactor(backend): cleaning user related old password junk [`#2426`](https://github.com/gradido/gradido/pull/2426)
- fix(database): consistent transaction table [`#2453`](https://github.com/gradido/gradido/pull/2453)
- refactor(backend): dissolve admin resolver [`#2416`](https://github.com/gradido/gradido/pull/2416)
- fix(backend): email verification code never expired [`#2418`](https://github.com/gradido/gradido/pull/2418)
- fix(database): consistent deleted at bewteen users and user contacts [`#2451`](https://github.com/gradido/gradido/pull/2451)
- feat(backend): log client timezone offset [`#2454`](https://github.com/gradido/gradido/pull/2454)
- refactor(backend): refactor more emails to translatables [`#2398`](https://github.com/gradido/gradido/pull/2398)
- fix(backend): delete / undelete email contact as well [`#2444`](https://github.com/gradido/gradido/pull/2444)
- feat(backend): 🍰 Mark creation via link [`#2363`](https://github.com/gradido/gradido/pull/2363)
- fix(backend): run all timers for high values [`#2452`](https://github.com/gradido/gradido/pull/2452)
- fix(backend): critical bug [`#2443`](https://github.com/gradido/gradido/pull/2443)
- fix(other): missing files for docker production build [`#2442`](https://github.com/gradido/gradido/pull/2442)
- fix(frontend): in contribution messages formular a message can be send twice, when clicking the submit button fast [`#2424`](https://github.com/gradido/gradido/pull/2424)
- fix(backend): wrong month for contribution near turn of month [`#2201`](https://github.com/gradido/gradido/pull/2201)
- feat(backend): add federation config properties [`#2374`](https://github.com/gradido/gradido/pull/2374)
- fix(backend): moved all jest & type-definition related packages into the `devDependencies` section [`#2385`](https://github.com/gradido/gradido/pull/2385)
#### [1.15.0](https://github.com/gradido/gradido/compare/1.14.1...1.15.0)
> 26 November 2022
- chore(release): v1.15.0 [`#2425`](https://github.com/gradido/gradido/pull/2425)
- fix(database): wrong balance and decay values [`#2423`](https://github.com/gradido/gradido/pull/2423)
- fix(backend): wrong balance after transaction receive [`#2422`](https://github.com/gradido/gradido/pull/2422)
- feat(other): feature gradido roadmap [`#2301`](https://github.com/gradido/gradido/pull/2301)

View File

@ -3,7 +3,7 @@
"description": "Administraion Interface for Gradido",
"main": "index.js",
"author": "Moriz Wahl",
"version": "1.15.0",
"version": "1.16.0",
"license": "Apache-2.0",
"private": false,
"scripts": {

View File

@ -107,9 +107,7 @@ COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json
COPY --from=build ${DOCKER_WORKDIR}/tsconfig.json ./tsconfig.json
# Copy log4js-config.json to provide log configuration
COPY --from=build ${DOCKER_WORKDIR}/log4js-config.json ./log4js-config.json
# Copy memonic type since its referenced in the sources
# TODO: remove
COPY --from=build ${DOCKER_WORKDIR}/src/config/mnemonic.uncompressed_buffer13116.txt ./src/config/mnemonic.uncompressed_buffer13116.txt
# Copy run scripts run/
# COPY --from=build ${DOCKER_WORKDIR}/run ./run

View File

@ -9,9 +9,10 @@ module.exports = {
modulePathIgnorePatterns: ['<rootDir>/build/'],
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1',
'@model/(.*)': '<rootDir>/src/graphql/model/$1',
'@arg/(.*)': '<rootDir>/src/graphql/arg/$1',
'@enum/(.*)': '<rootDir>/src/graphql/enum/$1',
'@model/(.*)': '<rootDir>/src/graphql/model/$1',
'@union/(.*)': '<rootDir>/src/graphql/union/$1',
'@repository/(.*)': '<rootDir>/src/typeorm/repository/$1',
'@test/(.*)': '<rootDir>/test/$1',
'@entity/(.*)':

View File

@ -1,6 +1,6 @@
{
"name": "gradido-backend",
"version": "1.15.0",
"version": "1.16.0",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend",

View File

@ -1,5 +1,5 @@
import { JwtPayload } from 'jsonwebtoken'
export interface CustomJwtPayload extends JwtPayload {
pubKey: Buffer
gradidoID: string
}

View File

@ -11,8 +11,8 @@ export const decode = (token: string): CustomJwtPayload | null => {
}
}
export const encode = (pubKey: Buffer): string => {
const token = jwt.sign({ pubKey }, CONFIG.JWT_SECRET, {
export const encode = (gradidoID: string): string => {
const token = jwt.sign({ gradidoID }, CONFIG.JWT_SECRET, {
expiresIn: CONFIG.JWT_EXPIRES_IN,
})
return token

View File

@ -10,7 +10,7 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0055-consistent_deleted_users',
DB_VERSION: '0057-clear_old_password_junk',
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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -5,9 +5,8 @@ import { AuthChecker } from 'type-graphql'
import { decode, encode } from '@/auth/JWT'
import { ROLE_UNAUTHORIZED, ROLE_USER, ROLE_ADMIN } from '@/auth/ROLES'
import { RIGHTS } from '@/auth/RIGHTS'
import { getCustomRepository } from '@dbTools/typeorm'
import { UserRepository } from '@repository/User'
import { INALIENABLE_RIGHTS } from '@/auth/INALIENABLE_RIGHTS'
import { User } from '@entity/User'
const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
context.role = ROLE_UNAUTHORIZED // unauthorized user
@ -26,14 +25,16 @@ const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
if (!decoded) {
throw new Error('403.13 - Client certificate revoked')
}
// Set context pubKey
context.pubKey = Buffer.from(decoded.pubKey).toString('hex')
// Set context gradidoID
context.gradidoID = decoded.gradidoID
// 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 = getCustomRepository(UserRepository)
try {
const user = await userRepository.findByPubkeyHex(context.pubKey)
const user = await User.findOneOrFail({
where: { gradidoID: decoded.gradidoID },
relations: ['emailContact'],
})
context.user = user
context.role = user.isAdmin ? ROLE_ADMIN : ROLE_USER
} catch {
@ -48,7 +49,7 @@ const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
}
// set new header token
context.setHeaders.push({ key: 'token', value: encode(decoded.pubKey) })
context.setHeaders.push({ key: 'token', value: encode(decoded.gradidoID) })
return true
}

File diff suppressed because it is too large Load Diff

View File

@ -1,924 +0,0 @@
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { backendLogger as logger } from '@/server/logger'
import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type-graphql'
import {
getCustomRepository,
IsNull,
getConnection,
In,
MoreThan,
FindOperator,
} from '@dbTools/typeorm'
import { UserAdmin, SearchUsersResult } from '@model/UserAdmin'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { AdminCreateContributions } from '@model/AdminCreateContributions'
import { AdminUpdateContribution } from '@model/AdminUpdateContribution'
import { ContributionLink } from '@model/ContributionLink'
import { ContributionLinkList } from '@model/ContributionLinkList'
import { Contribution } from '@model/Contribution'
import { RIGHTS } from '@/auth/RIGHTS'
import { UserRepository } from '@repository/User'
import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs'
import AdminUpdateContributionArgs from '@arg/AdminUpdateContributionArgs'
import SearchUsersArgs from '@arg/SearchUsersArgs'
import ContributionLinkArgs from '@arg/ContributionLinkArgs'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { calculateDecay } from '@/util/decay'
import { Contribution as DbContribution } from '@entity/Contribution'
import { hasElopageBuys } from '@/util/hasElopageBuys'
import { User as dbUser } from '@entity/User'
import { User } from '@model/User'
import { TransactionTypeId } from '@enum/TransactionTypeId'
import { ContributionType } from '@enum/ContributionType'
import { ContributionStatus } from '@enum/ContributionStatus'
import Decimal from 'decimal.js-light'
import { Decay } from '@model/Decay'
import Paginated from '@arg/Paginated'
import TransactionLinkFilters from '@arg/TransactionLinkFilters'
import { Order } from '@enum/Order'
import { getTimeDurationObject } from '@/util/time'
import { findUserByEmail, activationLink } from './UserResolver'
import {
sendAddedContributionMessageEmail,
sendAccountActivationEmail,
sendContributionConfirmedEmail,
sendContributionRejectedEmail,
} from '@/emails/sendEmailVariants'
import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver'
import CONFIG from '@/config'
import {
getUserCreation,
getUserCreations,
validateContribution,
isStartEndDateValid,
updateCreations,
isValidDateString,
} from './util/creations'
import {
CONTRIBUTIONLINK_NAME_MAX_CHARS,
CONTRIBUTIONLINK_NAME_MIN_CHARS,
FULL_CREATION_AVAILABLE,
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'
import { ContributionMessage } from '@model/ContributionMessage'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import {
Event,
EventAdminContributionCreate,
EventAdminContributionDelete,
EventAdminContributionUpdate,
EventContributionConfirm,
EventSendConfirmationEmail,
} from '@/event/Event'
import { ContributionListResult } from '../model/Contribution'
// const EMAIL_OPT_IN_REGISTER = 1
// const EMAIL_OPT_UNKNOWN = 3 // elopage?
@Resolver()
export class AdminResolver {
@Authorized([RIGHTS.SEARCH_USERS])
@Query(() => SearchUsersResult)
async searchUsers(
@Args()
{ searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs,
@Ctx() context: Context,
): Promise<SearchUsersResult> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const userRepository = getCustomRepository(UserRepository)
const userFields = [
'id',
'firstName',
'lastName',
'emailId',
'emailContact',
'deletedAt',
'isAdmin',
]
const [users, count] = await userRepository.findBySearchCriteriaPagedFiltered(
userFields.map((fieldName) => {
return 'user.' + fieldName
}),
searchText,
filters,
currentPage,
pageSize,
)
if (users.length === 0) {
return {
userCount: 0,
userList: [],
}
}
const creations = await getUserCreations(
users.map((u) => u.id),
clientTimezoneOffset,
)
const adminUsers = await Promise.all(
users.map(async (user) => {
let emailConfirmationSend = ''
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.emailContact.email),
emailConfirmationSend,
)
return adminUser
}),
)
return {
userCount: count,
userList: adminUsers,
}
}
@Authorized([RIGHTS.SET_USER_ROLE])
@Mutation(() => Date, { nullable: true })
async setUserRole(
@Arg('userId', () => Int)
userId: number,
@Arg('isAdmin', () => Boolean)
isAdmin: boolean,
@Ctx()
context: Context,
): Promise<Date | null> {
const user = await dbUser.findOne({ id: userId })
// user exists ?
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
}
// administrator user changes own role?
const moderatorUser = getUser(context)
if (moderatorUser.id === userId) {
logger.error('Administrator can not change his own role!')
throw new Error('Administrator can not change his own role!')
}
// change isAdmin
switch (user.isAdmin) {
case null:
if (isAdmin === true) {
user.isAdmin = new Date()
} else {
logger.error('User is already a usual user!')
throw new Error('User is already a usual user!')
}
break
default:
if (isAdmin === false) {
user.isAdmin = null
} else {
logger.error('User is already admin!')
throw new Error('User is already admin!')
}
break
}
await user.save()
const newUser = await dbUser.findOne({ id: userId })
return newUser ? newUser.isAdmin : null
}
@Authorized([RIGHTS.DELETE_USER])
@Mutation(() => Date, { nullable: true })
async deleteUser(
@Arg('userId', () => Int) userId: number,
@Ctx() context: Context,
): Promise<Date | null> {
const user = await dbUser.findOne({ where: { id: userId }, relations: ['emailContact'] })
// user exists ?
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
}
// moderator user disabled own account?
const moderatorUser = getUser(context)
if (moderatorUser.id === userId) {
logger.error('Moderator can not delete his own account!')
throw new Error('Moderator can not delete his own account!')
}
// soft-delete user
await user.softRemove()
await user.emailContact.softRemove()
const newUser = await dbUser.findOne({ id: userId }, { withDeleted: true })
return newUser ? newUser.deletedAt : null
}
@Authorized([RIGHTS.UNDELETE_USER])
@Mutation(() => Date, { nullable: true })
async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise<Date | null> {
const user = await dbUser.findOne(
{ id: userId },
{ withDeleted: true, relations: ['emailContact'] },
)
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
}
if (!user.deletedAt) {
logger.error('User is not deleted')
throw new Error('User is not deleted')
}
await user.recover()
await user.emailContact.recover()
return null
}
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION])
@Mutation(() => [Number])
async adminCreateContribution(
@Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs,
@Ctx() context: Context,
): Promise<Decimal[]> {
logger.info(
`adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`,
)
const clientTimezoneOffset = getClientTimezoneOffset(context)
if (!isValidDateString(creationDate)) {
logger.error(`invalid Date for creationDate=${creationDate}`)
throw new Error(`invalid Date for 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 (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 (!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 event = new Event()
const moderator = getUser(context)
logger.trace('moderator: ', moderator.id)
const creations = await getUserCreation(emailContact.userId, clientTimezoneOffset)
logger.trace('creations:', creations)
const creationDateObj = new Date(creationDate)
logger.trace('creationDateObj:', creationDateObj)
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contribution = DbContribution.create()
contribution.userId = emailContact.userId
contribution.amount = amount
contribution.createdAt = new Date()
contribution.contributionDate = creationDateObj
contribution.memo = memo
contribution.moderatorId = moderator.id
contribution.contributionType = ContributionType.ADMIN
contribution.contributionStatus = ContributionStatus.PENDING
logger.trace('contribution to save', contribution)
await DbContribution.save(contribution)
const eventAdminCreateContribution = new EventAdminContributionCreate()
eventAdminCreateContribution.userId = moderator.id
eventAdminCreateContribution.amount = amount
eventAdminCreateContribution.contributionId = contribution.id
await eventProtocol.writeEvent(
event.setEventAdminContributionCreate(eventAdminCreateContribution),
)
return getUserCreation(emailContact.userId, clientTimezoneOffset)
}
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS])
@Mutation(() => AdminCreateContributions)
async adminCreateContributions(
@Arg('pendingCreations', () => [AdminCreateContributionArgs])
contributions: AdminCreateContributionArgs[],
@Ctx() context: Context,
): Promise<AdminCreateContributions> {
let success = false
const successfulContribution: string[] = []
const failedContribution: string[] = []
for (const contribution of contributions) {
await this.adminCreateContribution(contribution, context)
.then(() => {
successfulContribution.push(contribution.email)
success = true
})
.catch(() => {
failedContribution.push(contribution.email)
})
}
return {
success,
successfulContribution,
failedContribution,
}
}
@Authorized([RIGHTS.ADMIN_UPDATE_CONTRIBUTION])
@Mutation(() => AdminUpdateContribution)
async adminUpdateContribution(
@Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs,
@Ctx() context: Context,
): Promise<AdminUpdateContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
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) {
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})`)
}
const moderator = getUser(context)
const contributionToUpdate = await DbContribution.findOne({
where: { id, confirmedAt: IsNull() },
})
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.')
}
const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id, clientTimezoneOffset)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset)
} else {
logger.error('Currently the month of the contribution cannot change.')
throw new Error('Currently the month of the contribution cannot change.')
}
// all possible cases not to be true are thrown in this function
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
contributionToUpdate.amount = amount
contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate)
contributionToUpdate.moderatorId = moderator.id
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
await DbContribution.save(contributionToUpdate)
const result = new AdminUpdateContribution()
result.amount = amount
result.memo = contributionToUpdate.memo
result.date = contributionToUpdate.contributionDate
result.creation = await getUserCreation(user.id, clientTimezoneOffset)
const event = new Event()
const eventAdminContributionUpdate = new EventAdminContributionUpdate()
eventAdminContributionUpdate.userId = user.id
eventAdminContributionUpdate.amount = amount
eventAdminContributionUpdate.contributionId = contributionToUpdate.id
await eventProtocol.writeEvent(
event.setEventAdminContributionUpdate(eventAdminContributionUpdate),
)
return result
}
@Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS])
@Query(() => [UnconfirmedContribution])
async listUnconfirmedContributions(@Ctx() context: Context): Promise<UnconfirmedContribution[]> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const contributions = await getConnection()
.createQueryBuilder()
.select('c')
.from(DbContribution, 'c')
.leftJoinAndSelect('c.messages', 'm')
.where({ confirmedAt: IsNull() })
.getMany()
if (contributions.length === 0) {
return []
}
const userIds = contributions.map((p) => p.userId)
const userCreations = await getUserCreations(userIds, clientTimezoneOffset)
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)
const creation = userCreations.find((c) => c.id === contribution.userId)
return new UnconfirmedContribution(
contribution,
user,
creation ? creation.creations : FULL_CREATION_AVAILABLE,
)
})
}
@Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION])
@Mutation(() => Boolean)
async adminDeleteContribution(
@Arg('id', () => Int) id: number,
@Ctx() context: Context,
): Promise<boolean> {
const contribution = await DbContribution.findOne(id)
if (!contribution) {
logger.error(`Contribution not found for given id: ${id}`)
throw new Error('Contribution not found for given id.')
}
const moderator = getUser(context)
if (
contribution.contributionType === ContributionType.USER &&
contribution.userId === moderator.id
) {
throw new Error('Own contribution can not be deleted as admin')
}
const user = await dbUser.findOneOrFail(
{ id: contribution.userId },
{ relations: ['emailContact'] },
)
contribution.contributionStatus = ContributionStatus.DELETED
contribution.deletedBy = moderator.id
await contribution.save()
const res = await contribution.softRemove()
const event = new Event()
const eventAdminContributionDelete = new EventAdminContributionDelete()
eventAdminContributionDelete.userId = contribution.userId
eventAdminContributionDelete.amount = contribution.amount
eventAdminContributionDelete.contributionId = contribution.id
await eventProtocol.writeEvent(
event.setEventAdminContributionDelete(eventAdminContributionDelete),
)
sendContributionRejectedEmail({
firstName: user.firstName,
lastName: user.lastName,
email: user.emailContact.email,
language: user.language,
senderFirstName: moderator.firstName,
senderLastName: moderator.lastName,
contributionMemo: contribution.memo,
})
return !!res
}
@Authorized([RIGHTS.CONFIRM_CONTRIBUTION])
@Mutation(() => Boolean)
async confirmContribution(
@Arg('id', () => Int) id: number,
@Ctx() context: Context,
): Promise<boolean> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const contribution = await DbContribution.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) {
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, 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, clientTimezoneOffset, false)
validateContribution(
creations,
contribution.amount,
contribution.contributionDate,
clientTimezoneOffset,
)
const receivedCallDate = new Date()
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED')
try {
const lastTransaction = await queryRunner.manager
.createQueryBuilder()
.select('transaction')
.from(DbTransaction, 'transaction')
.where('transaction.userId = :id', { id: contribution.userId })
.orderBy('transaction.balanceDate', 'DESC')
.getOne()
logger.info('lastTransaction ID', lastTransaction ? lastTransaction.id : 'undefined')
let newBalance = new Decimal(0)
let decay: Decay | null = null
if (lastTransaction) {
decay = calculateDecay(
lastTransaction.balance,
lastTransaction.balanceDate,
receivedCallDate,
)
newBalance = decay.balance
}
newBalance = newBalance.add(contribution.amount.toString())
const transaction = new DbTransaction()
transaction.typeId = TransactionTypeId.CREATION
transaction.memo = contribution.memo
transaction.userId = contribution.userId
transaction.previous = lastTransaction ? lastTransaction.id : null
transaction.amount = contribution.amount
transaction.creationDate = contribution.contributionDate
transaction.balance = newBalance
transaction.balanceDate = receivedCallDate
transaction.decay = decay ? decay.decay : new Decimal(0)
transaction.decayStart = decay ? decay.start : null
await queryRunner.manager.insert(DbTransaction, transaction)
contribution.confirmedAt = receivedCallDate
contribution.confirmedBy = moderatorUser.id
contribution.transactionId = transaction.id
contribution.contributionStatus = ContributionStatus.CONFIRMED
await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution)
await queryRunner.commitTransaction()
logger.info('creation commited successfuly.')
sendContributionConfirmedEmail({
firstName: user.firstName,
lastName: user.lastName,
email: user.emailContact.email,
language: user.language,
senderFirstName: moderatorUser.firstName,
senderLastName: moderatorUser.lastName,
contributionMemo: contribution.memo,
contributionAmount: contribution.amount,
})
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`Creation was not successful: ${e}`)
throw new Error(`Creation was not successful.`)
} finally {
await queryRunner.release()
}
const event = new Event()
const eventContributionConfirm = new EventContributionConfirm()
eventContributionConfirm.userId = user.id
eventContributionConfirm.amount = contribution.amount
eventContributionConfirm.contributionId = contribution.id
await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm))
return true
}
@Authorized([RIGHTS.CREATION_TRANSACTION_LIST])
@Query(() => ContributionListResult)
async creationTransactionList(
@Args()
{ currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
@Arg('userId', () => Int) userId: number,
): Promise<ContributionListResult> {
const offset = (currentPage - 1) * pageSize
const [contributionResult, count] = await getConnection()
.createQueryBuilder()
.select('c')
.from(DbContribution, 'c')
.leftJoinAndSelect('c.user', 'u')
.where(`user_id = ${userId}`)
.limit(pageSize)
.offset(offset)
.orderBy('c.created_at', order)
.getManyAndCount()
return new ContributionListResult(
count,
contributionResult.map((contribution) => new Contribution(contribution, contribution.user)),
)
// return userTransactions.map((t) => new Transaction(t, new User(user), communityUser))
}
@Authorized([RIGHTS.SEND_ACTIVATION_EMAIL])
@Mutation(() => Boolean)
async sendActivationEmail(@Arg('email') email: string): Promise<boolean> {
email = email.trim().toLowerCase()
// 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 this User is deleted.`)
throw new Error(`The emailContact: ${email} of this User is deleted.`)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountActivationEmail({
firstName: user.firstName,
lastName: user.lastName,
email,
language: user.language,
activationLink: activationLink(emailContact.emailVerificationCode),
timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME),
})
// In case EMails are disabled log the activation link for the user
if (!emailSent) {
logger.info(`Account confirmation link: ${activationLink}`)
} else {
const event = new Event()
const eventSendConfirmationEmail = new EventSendConfirmationEmail()
eventSendConfirmationEmail.userId = user.id
await eventProtocol.writeEvent(
event.setEventSendConfirmationEmail(eventSendConfirmationEmail),
)
}
return true
}
@Authorized([RIGHTS.LIST_TRANSACTION_LINKS_ADMIN])
@Query(() => TransactionLinkResult)
async listTransactionLinksAdmin(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Arg('filters', () => TransactionLinkFilters, { nullable: true })
filters: TransactionLinkFilters,
@Arg('userId', () => Int)
userId: number,
): Promise<TransactionLinkResult> {
const user = await dbUser.findOneOrFail({ id: userId })
const where: {
userId: number
redeemedBy?: number | null
validUntil?: FindOperator<Date> | null
} = {
userId,
redeemedBy: null,
validUntil: MoreThan(new Date()),
}
if (filters) {
if (filters.withRedeemed) delete where.redeemedBy
if (filters.withExpired) delete where.validUntil
}
const [transactionLinks, count] = await dbTransactionLink.findAndCount({
where,
withDeleted: filters ? filters.withDeleted : false,
order: {
createdAt: order,
},
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
return {
linkCount: count,
linkList: transactionLinks.map((tl) => new TransactionLink(tl, new User(user))),
}
}
@Authorized([RIGHTS.CREATE_CONTRIBUTION_LINK])
@Mutation(() => ContributionLink)
async createContributionLink(
@Args()
{
amount,
name,
memo,
cycle,
validFrom,
validTo,
maxAmountPerMonth,
maxPerCycle,
}: ContributionLinkArgs,
): Promise<ContributionLink> {
isStartEndDateValid(validFrom, validTo)
if (!name) {
logger.error(`The name must be initialized!`)
throw new Error(`The name must be initialized!`)
}
if (
name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS ||
name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS
) {
const msg = `The value of 'name' with a length of ${name.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_NAME_MIN_CHARS} and max=${CONTRIBUTIONLINK_NAME_MAX_CHARS}`
logger.error(`${msg}`)
throw new Error(`${msg}`)
}
if (!memo) {
logger.error(`The memo must be initialized!`)
throw new Error(`The memo must be initialized!`)
}
if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) {
const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_CHARS}`
logger.error(`${msg}`)
throw new Error(`${msg}`)
}
if (!amount) {
logger.error(`The amount must be initialized!`)
throw new Error('The amount must be initialized!')
}
if (!new Decimal(amount).isPositive()) {
logger.error(`The amount=${amount} must be initialized with a positiv value!`)
throw new Error(`The amount=${amount} must be initialized with a positiv value!`)
}
const dbContributionLink = new DbContributionLink()
dbContributionLink.amount = amount
dbContributionLink.name = name
dbContributionLink.memo = memo
dbContributionLink.createdAt = new Date()
dbContributionLink.code = contributionLinkCode(dbContributionLink.createdAt)
dbContributionLink.cycle = cycle
if (validFrom) dbContributionLink.validFrom = new Date(validFrom)
if (validTo) dbContributionLink.validTo = new Date(validTo)
dbContributionLink.maxAmountPerMonth = maxAmountPerMonth
dbContributionLink.maxPerCycle = maxPerCycle
await dbContributionLink.save()
logger.debug(`createContributionLink successful!`)
return new ContributionLink(dbContributionLink)
}
@Authorized([RIGHTS.LIST_CONTRIBUTION_LINKS])
@Query(() => ContributionLinkList)
async listContributionLinks(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
): Promise<ContributionLinkList> {
const [links, count] = await DbContributionLink.findAndCount({
where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }],
order: { createdAt: order },
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
return {
links: links.map((link: DbContributionLink) => new ContributionLink(link)),
count,
}
}
@Authorized([RIGHTS.DELETE_CONTRIBUTION_LINK])
@Mutation(() => Date, { nullable: true })
async deleteContributionLink(@Arg('id', () => Int) id: number): Promise<Date | null> {
const contributionLink = await DbContributionLink.findOne(id)
if (!contributionLink) {
logger.error(`Contribution Link not found to given id: ${id}`)
throw new Error('Contribution Link not found to given id.')
}
await contributionLink.softRemove()
logger.debug(`deleteContributionLink successful!`)
const newContributionLink = await DbContributionLink.findOne({ id }, { withDeleted: true })
return newContributionLink ? newContributionLink.deletedAt : null
}
@Authorized([RIGHTS.UPDATE_CONTRIBUTION_LINK])
@Mutation(() => ContributionLink)
async updateContributionLink(
@Args()
{
amount,
name,
memo,
cycle,
validFrom,
validTo,
maxAmountPerMonth,
maxPerCycle,
}: ContributionLinkArgs,
@Arg('id', () => Int) id: number,
): Promise<ContributionLink> {
const dbContributionLink = await DbContributionLink.findOne(id)
if (!dbContributionLink) {
logger.error(`Contribution Link not found to given id: ${id}`)
throw new Error('Contribution Link not found to given id.')
}
dbContributionLink.amount = amount
dbContributionLink.name = name
dbContributionLink.memo = memo
dbContributionLink.cycle = cycle
if (validFrom) dbContributionLink.validFrom = new Date(validFrom)
if (validTo) dbContributionLink.validTo = new Date(validTo)
dbContributionLink.maxAmountPerMonth = maxAmountPerMonth
dbContributionLink.maxPerCycle = maxPerCycle
await dbContributionLink.save()
logger.debug(`updateContributionLink successful!`)
return new ContributionLink(dbContributionLink)
}
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE])
@Mutation(() => ContributionMessage)
async adminCreateContributionMessage(
@Args() { contributionId, message }: ContributionMessageArgs,
@Ctx() context: Context,
): Promise<ContributionMessage> {
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('REPEATABLE READ')
const contributionMessage = DbContributionMessage.create()
try {
const contribution = await DbContribution.findOne({
where: { id: contributionId },
relations: ['user'],
})
if (!contribution) {
logger.error('Contribution not found')
throw new Error('Contribution not found')
}
if (contribution.userId === user.id) {
logger.error('Admin can not answer on own contribution')
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
contributionMessage.userId = user.id
contributionMessage.type = ContributionMessageType.DIALOG
contributionMessage.isModerator = true
await queryRunner.manager.insert(DbContributionMessage, contributionMessage)
if (
contribution.contributionStatus === ContributionStatus.DELETED ||
contribution.contributionStatus === ContributionStatus.DENIED ||
contribution.contributionStatus === ContributionStatus.PENDING
) {
contribution.contributionStatus = ContributionStatus.IN_PROGRESS
await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution)
}
await sendAddedContributionMessageEmail({
firstName: contribution.user.firstName,
lastName: contribution.user.lastName,
email: contribution.user.emailContact.email,
language: contribution.user.language,
senderFirstName: user.firstName,
senderLastName: user.lastName,
contributionMemo: contribution.memo,
})
await queryRunner.commitTransaction()
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`ContributionMessage was not successful: ${e}`)
throw new Error(`ContributionMessage was not successful: ${e}`)
} finally {
await queryRunner.release()
}
return new ContributionMessage(contributionMessage, user)
}
}

View File

@ -1,16 +1,19 @@
import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context'
import Decimal from 'decimal.js-light'
import { Resolver, Query, Ctx, Authorized } from 'type-graphql'
import { getCustomRepository } from '@dbTools/typeorm'
import { Transaction as dbTransaction } from '@entity/Transaction'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { TransactionLinkRepository } from '@repository/TransactionLink'
import { Balance } from '@model/Balance'
import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context'
import { calculateDecay } from '@/util/decay'
import { RIGHTS } from '@/auth/RIGHTS'
import { Transaction as dbTransaction } from '@entity/Transaction'
import Decimal from 'decimal.js-light'
import { GdtResolver } from './GdtResolver'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { getCustomRepository } from '@dbTools/typeorm'
import { TransactionLinkRepository } from '@repository/TransactionLink'
@Resolver()
export class BalanceResolver {

View File

@ -1,7 +1,9 @@
import { Resolver, Query, Authorized } from 'type-graphql'
import { Community } from '@model/Community'
import { RIGHTS } from '@/auth/RIGHTS'
import CONFIG from '@/config'
import { Community } from '@model/Community'
@Resolver()
export class CommunityResolver {

View File

@ -0,0 +1,649 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import Decimal from 'decimal.js-light'
import { logger } from '@test/testSetup'
import { GraphQLError } from 'graphql'
import {
login,
createContributionLink,
deleteContributionLink,
updateContributionLink,
} from '@/seeds/graphql/mutations'
import { listContributionLinks } from '@/seeds/graphql/queries'
import { cleanDB, testEnvironment, resetToken } from '@test/helpers'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { userFactory } from '@/seeds/factory/user'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
let mutate: any, query: any, con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment()
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
await cleanDB()
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
})
afterAll(async () => {
await cleanDB()
await con.close()
})
describe('Contribution Links', () => {
const now = new Date()
const variables = {
amount: new Decimal(200),
name: 'Dokumenta 2022',
memo: 'Danke für deine Teilnahme an der Dokumenta 2022',
cycle: 'once',
validFrom: new Date(2022, 5, 18).toISOString(),
validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
}
describe('unauthenticated', () => {
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('createContributionLink', () => {
it('returns an error', async () => {
await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('listContributionLinks', () => {
it('returns an error', async () => {
await expect(query({ query: listContributionLinks })).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('updateContributionLink', () => {
it('returns an error', async () => {
await expect(
mutate({
mutation: updateContributionLink,
variables: {
...variables,
id: -1,
amount: new Decimal(400),
name: 'Dokumenta 2023',
memo: 'Danke für deine Teilnahme an der Dokumenta 2023',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('deleteContributionLink', () => {
it('returns an error', async () => {
await expect(
mutate({ mutation: deleteContributionLink, variables: { id: -1 } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
})
describe('authenticated', () => {
describe('without admin rights', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('createContributionLink', () => {
it('returns an error', async () => {
await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
// TODO: Set this test in new location to have datas
describe('listContributionLinks', () => {
it('returns an empty object', async () => {
await expect(query({ query: listContributionLinks })).resolves.toEqual(
expect.objectContaining({
data: {
listContributionLinks: {
count: 0,
links: [],
},
},
}),
)
})
})
describe('updateContributionLink', () => {
it('returns an error', async () => {
await expect(
mutate({
mutation: updateContributionLink,
variables: {
...variables,
id: -1,
amount: new Decimal(400),
name: 'Dokumenta 2023',
memo: 'Danke für deine Teilnahme an der Dokumenta 2023',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('deleteContributionLink', () => {
it('returns an error', async () => {
await expect(
mutate({ mutation: deleteContributionLink, variables: { id: -1 } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
})
describe('with admin rights', () => {
beforeAll(async () => {
await userFactory(testEnv, peterLustig)
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('createContributionLink', () => {
it('returns a contribution link object', async () => {
await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual(
expect.objectContaining({
data: {
createContributionLink: expect.objectContaining({
id: expect.any(Number),
amount: '200',
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/),
createdAt: expect.any(String),
name: 'Dokumenta 2022',
memo: 'Danke für deine Teilnahme an der Dokumenta 2022',
validFrom: expect.any(String),
validTo: expect.any(String),
maxAmountPerMonth: '200',
cycle: 'once',
maxPerCycle: 1,
}),
},
}),
)
})
it('has a contribution link stored in db', async () => {
const cls = await DbContributionLink.find()
expect(cls).toHaveLength(1)
expect(cls[0]).toEqual(
expect.objectContaining({
id: expect.any(Number),
name: 'Dokumenta 2022',
memo: 'Danke für deine Teilnahme an der Dokumenta 2022',
validFrom: new Date('2022-06-18T00:00:00.000Z'),
validTo: expect.any(Date),
cycle: 'once',
maxPerCycle: 1,
totalMaxCountOfContribution: null,
maxAccountBalance: null,
minGapHours: null,
createdAt: expect.any(Date),
deletedAt: null,
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
linkEnabled: true,
amount: expect.decimalEqual(200),
maxAmountPerMonth: expect.decimalEqual(200),
}),
)
})
it('returns an error if missing startDate', async () => {
await expect(
mutate({
mutation: createContributionLink,
variables: {
...variables,
validFrom: null,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError('Start-Date is not initialized. A Start-Date must be set!'),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'Start-Date is not initialized. A Start-Date must be set!',
)
})
it('returns an error if missing endDate', async () => {
await expect(
mutate({
mutation: createContributionLink,
variables: {
...variables,
validTo: null,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('End-Date is not initialized. An End-Date must be set!')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'End-Date is not initialized. An End-Date must be set!',
)
})
it('returns an error if endDate is before startDate', async () => {
await expect(
mutate({
mutation: createContributionLink,
variables: {
...variables,
validFrom: new Date('2022-06-18T00:00:00.001Z').toISOString(),
validTo: new Date('2022-06-18T00:00:00.000Z').toISOString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(`The value of validFrom must before or equals the validTo!`),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of validFrom must before or equals the validTo!`,
)
})
it('returns an error if name is an empty string', async () => {
await expect(
mutate({
mutation: createContributionLink,
variables: {
...variables,
name: '',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('The name must be initialized!')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The name must be initialized!')
})
it('returns an error if name is shorter than 5 characters', async () => {
await expect(
mutate({
mutation: createContributionLink,
variables: {
...variables,
name: '123',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
`The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`,
),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`,
)
})
it('returns an error if name is longer than 100 characters', async () => {
await expect(
mutate({
mutation: createContributionLink,
variables: {
...variables,
name: '12345678901234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
`The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`,
),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`,
)
})
it('returns an error if memo is an empty string', async () => {
await expect(
mutate({
mutation: createContributionLink,
variables: {
...variables,
memo: '',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('The memo must be initialized!')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The memo must be initialized!')
})
it('returns an error if memo is shorter than 5 characters', async () => {
await expect(
mutate({
mutation: createContributionLink,
variables: {
...variables,
memo: '123',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
`The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`,
),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`,
)
})
it('returns an error if memo is longer than 255 characters', async () => {
await expect(
mutate({
mutation: createContributionLink,
variables: {
...variables,
memo: '1234567890123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789012345678921234567893123456789412345678951234567896123456789712345678981234567899123456789012345678901234567892123456789312345678941234567895123456',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
`The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`,
),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`,
)
})
it('returns an error if amount is not positive', async () => {
await expect(
mutate({
mutation: createContributionLink,
variables: {
...variables,
amount: new Decimal(0),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('The amount=0 must be initialized with a positiv value!')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The amount=0 must be initialized with a positiv value!',
)
})
})
describe('listContributionLinks', () => {
describe('one link in DB', () => {
it('returns the link and count 1', async () => {
await expect(query({ query: listContributionLinks })).resolves.toEqual(
expect.objectContaining({
data: {
listContributionLinks: {
links: expect.arrayContaining([
expect.objectContaining({
amount: '200',
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/),
createdAt: expect.any(String),
name: 'Dokumenta 2022',
memo: 'Danke für deine Teilnahme an der Dokumenta 2022',
validFrom: expect.any(String),
validTo: expect.any(String),
maxAmountPerMonth: '200',
cycle: 'once',
maxPerCycle: 1,
}),
]),
count: 1,
},
},
}),
)
})
})
})
describe('updateContributionLink', () => {
describe('no valid id', () => {
it('returns an error', async () => {
await expect(
mutate({
mutation: updateContributionLink,
variables: {
...variables,
id: -1,
amount: new Decimal(400),
name: 'Dokumenta 2023',
memo: 'Danke für deine Teilnahme an der Dokumenta 2023',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Contribution Link not found to given id.')],
}),
)
})
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1')
})
describe('valid id', () => {
let linkId: number
beforeAll(async () => {
const links = await query({ query: listContributionLinks })
linkId = links.data.listContributionLinks.links[0].id
})
it('returns updated contribution link object', async () => {
await expect(
mutate({
mutation: updateContributionLink,
variables: {
...variables,
id: linkId,
amount: new Decimal(400),
name: 'Dokumenta 2023',
memo: 'Danke für deine Teilnahme an der Dokumenta 2023',
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
updateContributionLink: {
id: linkId,
amount: '400',
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/),
createdAt: expect.any(String),
name: 'Dokumenta 2023',
memo: 'Danke für deine Teilnahme an der Dokumenta 2023',
validFrom: expect.any(String),
validTo: expect.any(String),
maxAmountPerMonth: '200',
cycle: 'once',
maxPerCycle: 1,
},
},
}),
)
})
it('updated the DB record', async () => {
await expect(DbContributionLink.findOne(linkId)).resolves.toEqual(
expect.objectContaining({
id: linkId,
name: 'Dokumenta 2023',
memo: 'Danke für deine Teilnahme an der Dokumenta 2023',
amount: expect.decimalEqual(400),
}),
)
})
})
})
describe('deleteContributionLink', () => {
describe('no valid id', () => {
it('returns an error', async () => {
await expect(
mutate({ mutation: deleteContributionLink, variables: { id: -1 } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Contribution Link not found to given id.')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1')
})
})
describe('valid id', () => {
let linkId: number
beforeAll(async () => {
const links = await query({ query: listContributionLinks })
linkId = links.data.listContributionLinks.links[0].id
})
it('returns a date string', async () => {
await expect(
mutate({ mutation: deleteContributionLink, variables: { id: linkId } }),
).resolves.toEqual(
expect.objectContaining({
data: {
deleteContributionLink: expect.any(String),
},
}),
)
})
it('does not list this contribution link anymore', async () => {
await expect(query({ query: listContributionLinks })).resolves.toEqual(
expect.objectContaining({
data: {
listContributionLinks: {
links: [],
count: 0,
},
},
}),
)
})
})
})
})
})
})

View File

@ -0,0 +1,152 @@
import Decimal from 'decimal.js-light'
import { Resolver, Args, Arg, Authorized, Mutation, Query, Int } from 'type-graphql'
import { MoreThan, IsNull } from '@dbTools/typeorm'
import {
CONTRIBUTIONLINK_NAME_MAX_CHARS,
CONTRIBUTIONLINK_NAME_MIN_CHARS,
MEMO_MAX_CHARS,
MEMO_MIN_CHARS,
} from './const/const'
import { isStartEndDateValid } from './util/creations'
import { ContributionLinkList } from '@model/ContributionLinkList'
import { ContributionLink } from '@model/ContributionLink'
import ContributionLinkArgs from '@arg/ContributionLinkArgs'
import { backendLogger as logger } from '@/server/logger'
import { RIGHTS } from '@/auth/RIGHTS'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { Order } from '@enum/Order'
import Paginated from '@arg/Paginated'
// TODO: this is a strange construct
import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver'
@Resolver()
export class ContributionLinkResolver {
@Authorized([RIGHTS.CREATE_CONTRIBUTION_LINK])
@Mutation(() => ContributionLink)
async createContributionLink(
@Args()
{
amount,
name,
memo,
cycle,
validFrom,
validTo,
maxAmountPerMonth,
maxPerCycle,
}: ContributionLinkArgs,
): Promise<ContributionLink> {
isStartEndDateValid(validFrom, validTo)
if (!name) {
logger.error(`The name must be initialized!`)
throw new Error(`The name must be initialized!`)
}
if (
name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS ||
name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS
) {
const msg = `The value of 'name' with a length of ${name.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_NAME_MIN_CHARS} and max=${CONTRIBUTIONLINK_NAME_MAX_CHARS}`
logger.error(`${msg}`)
throw new Error(`${msg}`)
}
if (!memo) {
logger.error(`The memo must be initialized!`)
throw new Error(`The memo must be initialized!`)
}
if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) {
const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_CHARS}`
logger.error(`${msg}`)
throw new Error(`${msg}`)
}
if (!amount) {
logger.error(`The amount must be initialized!`)
throw new Error('The amount must be initialized!')
}
if (!new Decimal(amount).isPositive()) {
logger.error(`The amount=${amount} must be initialized with a positiv value!`)
throw new Error(`The amount=${amount} must be initialized with a positiv value!`)
}
const dbContributionLink = new DbContributionLink()
dbContributionLink.amount = amount
dbContributionLink.name = name
dbContributionLink.memo = memo
dbContributionLink.createdAt = new Date()
dbContributionLink.code = contributionLinkCode(dbContributionLink.createdAt)
dbContributionLink.cycle = cycle
if (validFrom) dbContributionLink.validFrom = new Date(validFrom)
if (validTo) dbContributionLink.validTo = new Date(validTo)
dbContributionLink.maxAmountPerMonth = maxAmountPerMonth
dbContributionLink.maxPerCycle = maxPerCycle
await dbContributionLink.save()
logger.debug(`createContributionLink successful!`)
return new ContributionLink(dbContributionLink)
}
@Authorized([RIGHTS.LIST_CONTRIBUTION_LINKS])
@Query(() => ContributionLinkList)
async listContributionLinks(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
): Promise<ContributionLinkList> {
const [links, count] = await DbContributionLink.findAndCount({
where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }],
order: { createdAt: order },
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
return {
links: links.map((link: DbContributionLink) => new ContributionLink(link)),
count,
}
}
@Authorized([RIGHTS.DELETE_CONTRIBUTION_LINK])
@Mutation(() => Date, { nullable: true })
async deleteContributionLink(@Arg('id', () => Int) id: number): Promise<Date | null> {
const contributionLink = await DbContributionLink.findOne(id)
if (!contributionLink) {
logger.error(`Contribution Link not found to given id: ${id}`)
throw new Error('Contribution Link not found to given id.')
}
await contributionLink.softRemove()
logger.debug(`deleteContributionLink successful!`)
const newContributionLink = await DbContributionLink.findOne({ id }, { withDeleted: true })
return newContributionLink ? newContributionLink.deletedAt : null
}
@Authorized([RIGHTS.UPDATE_CONTRIBUTION_LINK])
@Mutation(() => ContributionLink)
async updateContributionLink(
@Args()
{
amount,
name,
memo,
cycle,
validFrom,
validTo,
maxAmountPerMonth,
maxPerCycle,
}: ContributionLinkArgs,
@Arg('id', () => Int) id: number,
): Promise<ContributionLink> {
const dbContributionLink = await DbContributionLink.findOne(id)
if (!dbContributionLink) {
logger.error(`Contribution Link not found to given id: ${id}`)
throw new Error('Contribution Link not found to given id.')
}
dbContributionLink.amount = amount
dbContributionLink.name = name
dbContributionLink.memo = memo
dbContributionLink.cycle = cycle
if (validFrom) dbContributionLink.validFrom = new Date(validFrom)
if (validTo) dbContributionLink.validTo = new Date(validTo)
dbContributionLink.maxAmountPerMonth = maxAmountPerMonth
dbContributionLink.maxPerCycle = maxPerCycle
await dbContributionLink.save()
logger.debug(`updateContributionLink successful!`)
return new ContributionLink(dbContributionLink)
}
}

View File

@ -1,16 +1,21 @@
import { Arg, Args, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql'
import { getConnection } from '@dbTools/typeorm'
import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage'
import { Contribution as DbContribution } from '@entity/Contribution'
import { UserContact } from '@entity/UserContact'
import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage'
import ContributionMessageArgs from '@arg/ContributionMessageArgs'
import { ContributionMessageType } from '@enum/MessageType'
import { ContributionStatus } from '@enum/ContributionStatus'
import { Order } from '@enum/Order'
import Paginated from '@arg/Paginated'
import { backendLogger as logger } from '@/server/logger'
import { RIGHTS } from '@/auth/RIGHTS'
import { Context, getUser } from '@/server/context'
import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage'
import { Arg, Args, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql'
import ContributionMessageArgs from '@arg/ContributionMessageArgs'
import { Contribution } from '@entity/Contribution'
import { ContributionMessageType } from '@enum/MessageType'
import { ContributionStatus } from '@enum/ContributionStatus'
import { getConnection } from '@dbTools/typeorm'
import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage'
import Paginated from '@arg/Paginated'
import { Order } from '@enum/Order'
import { sendAddedContributionMessageEmail } from '@/emails/sendEmailVariants'
@Resolver()
export class ContributionMessageResolver {
@ -26,7 +31,7 @@ export class ContributionMessageResolver {
await queryRunner.startTransaction('REPEATABLE READ')
const contributionMessage = DbContributionMessage.create()
try {
const contribution = await Contribution.findOne({ id: contributionId })
const contribution = await DbContribution.findOne({ id: contributionId })
if (!contribution) {
throw new Error('Contribution not found')
}
@ -44,7 +49,7 @@ export class ContributionMessageResolver {
if (contribution.contributionStatus === ContributionStatus.IN_PROGRESS) {
contribution.contributionStatus = ContributionStatus.PENDING
await queryRunner.manager.update(Contribution, { id: contributionId }, contribution)
await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution)
}
await queryRunner.commitTransaction()
} catch (e) {
@ -82,4 +87,73 @@ export class ContributionMessageResolver {
),
}
}
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE])
@Mutation(() => ContributionMessage)
async adminCreateContributionMessage(
@Args() { contributionId, message }: ContributionMessageArgs,
@Ctx() context: Context,
): Promise<ContributionMessage> {
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('REPEATABLE READ')
const contributionMessage = DbContributionMessage.create()
try {
const contribution = await DbContribution.findOne({
where: { id: contributionId },
relations: ['user'],
})
if (!contribution) {
logger.error('Contribution not found')
throw new Error('Contribution not found')
}
if (contribution.userId === user.id) {
logger.error('Admin can not answer on own contribution')
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
contributionMessage.userId = user.id
contributionMessage.type = ContributionMessageType.DIALOG
contributionMessage.isModerator = true
await queryRunner.manager.insert(DbContributionMessage, contributionMessage)
if (
contribution.contributionStatus === ContributionStatus.DELETED ||
contribution.contributionStatus === ContributionStatus.DENIED ||
contribution.contributionStatus === ContributionStatus.PENDING
) {
contribution.contributionStatus = ContributionStatus.IN_PROGRESS
await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution)
}
await sendAddedContributionMessageEmail({
firstName: contribution.user.firstName,
lastName: contribution.user.lastName,
email: contribution.user.emailContact.email,
language: contribution.user.language,
senderFirstName: user.firstName,
senderLastName: user.lastName,
contributionMemo: contribution.memo,
})
await queryRunner.commitTransaction()
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`ContributionMessage was not successful: ${e}`)
throw new Error(`ContributionMessage was not successful: ${e}`)
} finally {
await queryRunner.release()
}
return new ContributionMessage(contributionMessage, user)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +1,55 @@
import { RIGHTS } from '@/auth/RIGHTS'
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { backendLogger as logger } from '@/server/logger'
import { Contribution as dbContribution } from '@entity/Contribution'
import Decimal from 'decimal.js-light'
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
import { FindOperator, IsNull, getConnection } from '@dbTools/typeorm'
import ContributionArgs from '@arg/ContributionArgs'
import Paginated from '@arg/Paginated'
import { FindOperator, IsNull, In, getConnection } from '@dbTools/typeorm'
import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionMessage } from '@entity/ContributionMessage'
import { UserContact } from '@entity/UserContact'
import { User as DbUser } from '@entity/User'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { AdminCreateContributions } from '@model/AdminCreateContributions'
import { AdminUpdateContribution } from '@model/AdminUpdateContribution'
import { Contribution, ContributionListResult } from '@model/Contribution'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { Decay } from '@model/Decay'
import { TransactionTypeId } from '@enum/TransactionTypeId'
import { Order } from '@enum/Order'
import { ContributionType } from '@enum/ContributionType'
import { ContributionStatus } from '@enum/ContributionStatus'
import { Contribution, ContributionListResult } from '@model/Contribution'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { validateContribution, getUserCreation, updateCreations } from './util/creations'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
import { ContributionMessage } from '@entity/ContributionMessage'
import { ContributionMessageType } from '@enum/MessageType'
import ContributionArgs from '@arg/ContributionArgs'
import Paginated from '@arg/Paginated'
import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs'
import AdminUpdateContributionArgs from '@arg/AdminUpdateContributionArgs'
import { RIGHTS } from '@/auth/RIGHTS'
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { backendLogger as logger } from '@/server/logger'
import {
getUserCreation,
getUserCreations,
validateContribution,
updateCreations,
isValidDateString,
} from './util/creations'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS, FULL_CREATION_AVAILABLE } from './const/const'
import {
Event,
EventContributionCreate,
EventContributionDelete,
EventContributionUpdate,
EventContributionConfirm,
EventAdminContributionCreate,
EventAdminContributionDelete,
EventAdminContributionUpdate,
} from '@/event/Event'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import { calculateDecay } from '@/util/decay'
import {
sendContributionConfirmedEmail,
sendContributionRejectedEmail,
} from '@/emails/sendEmailVariants'
@Resolver()
export class ContributionResolver {
@ -50,7 +78,7 @@ export class ContributionResolver {
const creationDateObj = new Date(creationDate)
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contribution = dbContribution.create()
const contribution = DbContribution.create()
contribution.userId = user.id
contribution.amount = amount
contribution.createdAt = new Date()
@ -60,7 +88,7 @@ export class ContributionResolver {
contribution.contributionStatus = ContributionStatus.PENDING
logger.trace('contribution to save', contribution)
await dbContribution.save(contribution)
await DbContribution.save(contribution)
const eventCreateContribution = new EventContributionCreate()
eventCreateContribution.userId = user.id
@ -79,7 +107,7 @@ export class ContributionResolver {
): Promise<boolean> {
const event = new Event()
const user = getUser(context)
const contribution = await dbContribution.findOne(id)
const contribution = await DbContribution.findOne(id)
if (!contribution) {
logger.error('Contribution not found for given id')
throw new Error('Contribution not found for given id.')
@ -128,7 +156,7 @@ export class ContributionResolver {
const [contributions, count] = await getConnection()
.createQueryBuilder()
.select('c')
.from(dbContribution, 'c')
.from(DbContribution, 'c')
.leftJoinAndSelect('c.messages', 'm')
.where(where)
.withDeleted()
@ -152,7 +180,7 @@ export class ContributionResolver {
const [dbContributions, count] = await getConnection()
.createQueryBuilder()
.select('c')
.from(dbContribution, 'c')
.from(DbContribution, 'c')
.innerJoinAndSelect('c.user', 'u')
.orderBy('c.createdAt', order)
.limit(pageSize)
@ -185,7 +213,7 @@ export class ContributionResolver {
const user = getUser(context)
const contributionToUpdate = await dbContribution.findOne({
const contributionToUpdate = await DbContribution.findOne({
where: { id: contributionId, confirmedAt: IsNull() },
})
if (!contributionToUpdate) {
@ -240,7 +268,7 @@ export class ContributionResolver {
contributionToUpdate.contributionDate = new Date(creationDate)
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
contributionToUpdate.updatedAt = new Date()
dbContribution.save(contributionToUpdate)
DbContribution.save(contributionToUpdate)
const event = new Event()
@ -252,4 +280,403 @@ export class ContributionResolver {
return new UnconfirmedContribution(contributionToUpdate, user, creations)
}
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION])
@Mutation(() => [Number])
async adminCreateContribution(
@Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs,
@Ctx() context: Context,
): Promise<Decimal[]> {
logger.info(
`adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`,
)
const clientTimezoneOffset = getClientTimezoneOffset(context)
if (!isValidDateString(creationDate)) {
logger.error(`invalid Date for creationDate=${creationDate}`)
throw new Error(`invalid Date for 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 (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 (!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 event = new Event()
const moderator = getUser(context)
logger.trace('moderator: ', moderator.id)
const creations = await getUserCreation(emailContact.userId, clientTimezoneOffset)
logger.trace('creations:', creations)
const creationDateObj = new Date(creationDate)
logger.trace('creationDateObj:', creationDateObj)
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contribution = DbContribution.create()
contribution.userId = emailContact.userId
contribution.amount = amount
contribution.createdAt = new Date()
contribution.contributionDate = creationDateObj
contribution.memo = memo
contribution.moderatorId = moderator.id
contribution.contributionType = ContributionType.ADMIN
contribution.contributionStatus = ContributionStatus.PENDING
logger.trace('contribution to save', contribution)
await DbContribution.save(contribution)
const eventAdminCreateContribution = new EventAdminContributionCreate()
eventAdminCreateContribution.userId = moderator.id
eventAdminCreateContribution.amount = amount
eventAdminCreateContribution.contributionId = contribution.id
await eventProtocol.writeEvent(
event.setEventAdminContributionCreate(eventAdminCreateContribution),
)
return getUserCreation(emailContact.userId, clientTimezoneOffset)
}
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS])
@Mutation(() => AdminCreateContributions)
async adminCreateContributions(
@Arg('pendingCreations', () => [AdminCreateContributionArgs])
contributions: AdminCreateContributionArgs[],
@Ctx() context: Context,
): Promise<AdminCreateContributions> {
let success = false
const successfulContribution: string[] = []
const failedContribution: string[] = []
for (const contribution of contributions) {
await this.adminCreateContribution(contribution, context)
.then(() => {
successfulContribution.push(contribution.email)
success = true
})
.catch(() => {
failedContribution.push(contribution.email)
})
}
return {
success,
successfulContribution,
failedContribution,
}
}
@Authorized([RIGHTS.ADMIN_UPDATE_CONTRIBUTION])
@Mutation(() => AdminUpdateContribution)
async adminUpdateContribution(
@Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs,
@Ctx() context: Context,
): Promise<AdminUpdateContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
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) {
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})`)
}
const moderator = getUser(context)
const contributionToUpdate = await DbContribution.findOne({
where: { id, confirmedAt: IsNull() },
})
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.')
}
const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id, clientTimezoneOffset)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset)
} else {
logger.error('Currently the month of the contribution cannot change.')
throw new Error('Currently the month of the contribution cannot change.')
}
// all possible cases not to be true are thrown in this function
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
contributionToUpdate.amount = amount
contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate)
contributionToUpdate.moderatorId = moderator.id
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
await DbContribution.save(contributionToUpdate)
const result = new AdminUpdateContribution()
result.amount = amount
result.memo = contributionToUpdate.memo
result.date = contributionToUpdate.contributionDate
result.creation = await getUserCreation(user.id, clientTimezoneOffset)
const event = new Event()
const eventAdminContributionUpdate = new EventAdminContributionUpdate()
eventAdminContributionUpdate.userId = user.id
eventAdminContributionUpdate.amount = amount
eventAdminContributionUpdate.contributionId = contributionToUpdate.id
await eventProtocol.writeEvent(
event.setEventAdminContributionUpdate(eventAdminContributionUpdate),
)
return result
}
@Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS])
@Query(() => [UnconfirmedContribution])
async listUnconfirmedContributions(@Ctx() context: Context): Promise<UnconfirmedContribution[]> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const contributions = await getConnection()
.createQueryBuilder()
.select('c')
.from(DbContribution, 'c')
.leftJoinAndSelect('c.messages', 'm')
.where({ confirmedAt: IsNull() })
.getMany()
if (contributions.length === 0) {
return []
}
const userIds = contributions.map((p) => p.userId)
const userCreations = await getUserCreations(userIds, clientTimezoneOffset)
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)
const creation = userCreations.find((c) => c.id === contribution.userId)
return new UnconfirmedContribution(
contribution,
user,
creation ? creation.creations : FULL_CREATION_AVAILABLE,
)
})
}
@Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION])
@Mutation(() => Boolean)
async adminDeleteContribution(
@Arg('id', () => Int) id: number,
@Ctx() context: Context,
): Promise<boolean> {
const contribution = await DbContribution.findOne(id)
if (!contribution) {
logger.error(`Contribution not found for given id: ${id}`)
throw new Error('Contribution not found for given id.')
}
const moderator = getUser(context)
if (
contribution.contributionType === ContributionType.USER &&
contribution.userId === moderator.id
) {
throw new Error('Own contribution can not be deleted as admin')
}
const user = await DbUser.findOneOrFail(
{ id: contribution.userId },
{ relations: ['emailContact'] },
)
contribution.contributionStatus = ContributionStatus.DELETED
contribution.deletedBy = moderator.id
await contribution.save()
const res = await contribution.softRemove()
const event = new Event()
const eventAdminContributionDelete = new EventAdminContributionDelete()
eventAdminContributionDelete.userId = contribution.userId
eventAdminContributionDelete.amount = contribution.amount
eventAdminContributionDelete.contributionId = contribution.id
await eventProtocol.writeEvent(
event.setEventAdminContributionDelete(eventAdminContributionDelete),
)
sendContributionRejectedEmail({
firstName: user.firstName,
lastName: user.lastName,
email: user.emailContact.email,
language: user.language,
senderFirstName: moderator.firstName,
senderLastName: moderator.lastName,
contributionMemo: contribution.memo,
})
return !!res
}
@Authorized([RIGHTS.CONFIRM_CONTRIBUTION])
@Mutation(() => Boolean)
async confirmContribution(
@Arg('id', () => Int) id: number,
@Ctx() context: Context,
): Promise<boolean> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const contribution = await DbContribution.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) {
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, 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, clientTimezoneOffset, false)
validateContribution(
creations,
contribution.amount,
contribution.contributionDate,
clientTimezoneOffset,
)
const receivedCallDate = new Date()
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED')
try {
const lastTransaction = await queryRunner.manager
.createQueryBuilder()
.select('transaction')
.from(DbTransaction, 'transaction')
.where('transaction.userId = :id', { id: contribution.userId })
.orderBy('transaction.balanceDate', 'DESC')
.getOne()
logger.info('lastTransaction ID', lastTransaction ? lastTransaction.id : 'undefined')
let newBalance = new Decimal(0)
let decay: Decay | null = null
if (lastTransaction) {
decay = calculateDecay(
lastTransaction.balance,
lastTransaction.balanceDate,
receivedCallDate,
)
newBalance = decay.balance
}
newBalance = newBalance.add(contribution.amount.toString())
const transaction = new DbTransaction()
transaction.typeId = TransactionTypeId.CREATION
transaction.memo = contribution.memo
transaction.userId = contribution.userId
transaction.previous = lastTransaction ? lastTransaction.id : null
transaction.amount = contribution.amount
transaction.creationDate = contribution.contributionDate
transaction.balance = newBalance
transaction.balanceDate = receivedCallDate
transaction.decay = decay ? decay.decay : new Decimal(0)
transaction.decayStart = decay ? decay.start : null
await queryRunner.manager.insert(DbTransaction, transaction)
contribution.confirmedAt = receivedCallDate
contribution.confirmedBy = moderatorUser.id
contribution.transactionId = transaction.id
contribution.contributionStatus = ContributionStatus.CONFIRMED
await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution)
await queryRunner.commitTransaction()
logger.info('creation commited successfuly.')
sendContributionConfirmedEmail({
firstName: user.firstName,
lastName: user.lastName,
email: user.emailContact.email,
language: user.language,
senderFirstName: moderatorUser.firstName,
senderLastName: moderatorUser.lastName,
contributionMemo: contribution.memo,
contributionAmount: contribution.amount,
})
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`Creation was not successful: ${e}`)
throw new Error(`Creation was not successful.`)
} finally {
await queryRunner.release()
}
const event = new Event()
const eventContributionConfirm = new EventContributionConfirm()
eventContributionConfirm.userId = user.id
eventContributionConfirm.amount = contribution.amount
eventContributionConfirm.contributionId = contribution.id
await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm))
return true
}
@Authorized([RIGHTS.CREATION_TRANSACTION_LIST])
@Query(() => ContributionListResult)
async creationTransactionList(
@Args()
{ currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
@Arg('userId', () => Int) userId: number,
): Promise<ContributionListResult> {
const offset = (currentPage - 1) * pageSize
const [contributionResult, count] = await getConnection()
.createQueryBuilder()
.select('c')
.from(DbContribution, 'c')
.leftJoinAndSelect('c.user', 'u')
.where(`user_id = ${userId}`)
.limit(pageSize)
.offset(offset)
.orderBy('c.created_at', order)
.getManyAndCount()
return new ContributionListResult(
count,
contributionResult.map((contribution) => new Contribution(contribution, contribution.user)),
)
// return userTransactions.map((t) => new Transaction(t, new User(user), communityUser))
}
}

View File

@ -0,0 +1,126 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { testEnvironment, cleanDB } from '@test/helpers'
import { User as DbUser } from '@entity/User'
import { createUser, setPassword, forgotPassword } from '@/seeds/graphql/mutations'
import { queryOptIn } from '@/seeds/graphql/queries'
import CONFIG from '@/config'
import { GraphQLError } from 'graphql'
let mutate: any, query: any, con: any
let testEnv: any
CONFIG.EMAIL_CODE_VALID_TIME = 1440
CONFIG.EMAIL_CODE_REQUEST_TIME = 10
CONFIG.EMAIL = false
beforeAll(async () => {
testEnv = await testEnvironment()
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
describe('EmailOptinCodes', () => {
let optinCode: string
beforeAll(async () => {
const variables = {
email: 'peter@lustig.de',
firstName: 'Peter',
lastName: 'Lustig',
language: 'de',
}
const {
data: { createUser: user },
} = await mutate({ mutation: createUser, variables })
const dbObject = await DbUser.findOneOrFail({
where: { id: user.id },
relations: ['emailContact'],
})
optinCode = dbObject.emailContact.emailVerificationCode.toString()
})
describe('queryOptIn', () => {
it('has a valid optin code', async () => {
await expect(
query({ query: queryOptIn, variables: { optIn: optinCode } }),
).resolves.toMatchObject({
data: {
queryOptIn: true,
},
errors: undefined,
})
})
describe('run time forward until code must be expired', () => {
beforeAll(() => {
jest.useFakeTimers()
setTimeout(jest.fn(), CONFIG.EMAIL_CODE_VALID_TIME * 60 * 1000)
jest.runAllTimers()
})
afterAll(() => {
jest.useRealTimers()
})
it('throws an error', async () => {
await expect(
query({ query: queryOptIn, variables: { optIn: optinCode } }),
).resolves.toMatchObject({
data: null,
errors: [new GraphQLError('email was sent more than 24 hours ago')],
})
})
it('does not allow to set password', async () => {
await expect(
mutate({ mutation: setPassword, variables: { code: optinCode, password: 'Aa12345_' } }),
).resolves.toMatchObject({
data: null,
errors: [new GraphQLError('email was sent more than 24 hours ago')],
})
})
})
})
describe('forgotPassword', () => {
it('throws an error', async () => {
await expect(
mutate({ mutation: forgotPassword, variables: { email: 'peter@lustig.de' } }),
).resolves.toMatchObject({
data: null,
errors: [new GraphQLError('email already sent less than 10 minutes minutes ago')],
})
})
describe('run time forward until code can be resent', () => {
beforeAll(() => {
jest.useFakeTimers()
setTimeout(jest.fn(), CONFIG.EMAIL_CODE_REQUEST_TIME * 60 * 1000)
jest.runAllTimers()
})
afterAll(() => {
jest.useRealTimers()
})
it('cann send email again', async () => {
await expect(
mutate({ mutation: forgotPassword, variables: { email: 'peter@lustig.de' } }),
).resolves.toMatchObject({
data: {
forgotPassword: true,
},
errors: undefined,
})
})
})
})
})

View File

@ -1,10 +1,12 @@
import { Context, getUser } from '@/server/context'
import { Resolver, Query, Args, Ctx, Authorized, Arg } from 'type-graphql'
import CONFIG from '@/config'
import { GdtEntryList } from '@model/GdtEntryList'
import Paginated from '@arg/Paginated'
import { apiGet, apiPost } from '@/apis/HttpRequest'
import { Order } from '@enum/Order'
import Paginated from '@arg/Paginated'
import { Context, getUser } from '@/server/context'
import CONFIG from '@/config'
import { apiGet, apiPost } from '@/apis/HttpRequest'
import { RIGHTS } from '@/auth/RIGHTS'
@Resolver()

View File

@ -1,4 +1,7 @@
import { Resolver, Query, Authorized, Arg, Mutation, Args } from 'type-graphql'
import SubscribeNewsletterArgs from '@arg/SubscribeNewsletterArgs'
import {
getKlickTippUser,
getKlicktippTagMap,
@ -6,7 +9,6 @@ import {
klicktippSignIn,
} from '@/apis/KlicktippController'
import { RIGHTS } from '@/auth/RIGHTS'
import SubscribeNewsletterArgs from '@arg/SubscribeNewsletterArgs'
@Resolver()
export class KlicktippResolver {

View File

@ -1,10 +1,13 @@
import { Resolver, Query, Authorized } from 'type-graphql'
import { RIGHTS } from '@/auth/RIGHTS'
import { CommunityStatistics } from '@model/CommunityStatistics'
import { User as DbUser } from '@entity/User'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { getConnection } from '@dbTools/typeorm'
import Decimal from 'decimal.js-light'
import { Resolver, Query, Authorized } from 'type-graphql'
import { getConnection } from '@dbTools/typeorm'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { User as DbUser } from '@entity/User'
import { CommunityStatistics } from '@model/CommunityStatistics'
import { RIGHTS } from '@/auth/RIGHTS'
import { calculateDecay } from '@/util/decay'
/* eslint-disable @typescript-eslint/no-explicit-any */

View File

@ -4,8 +4,12 @@
import { transactionLinkCode } from './TransactionLinkResolver'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { cleanDB, testEnvironment } from '@test/helpers'
import { cleanDB, testEnvironment, resetToken } from '@test/helpers'
import { creationFactory } from '@/seeds/factory/creation'
import { creations } from '@/seeds/creation/index'
import { userFactory } from '@/seeds/factory/user'
import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
import { transactionLinks } from '@/seeds/transactionLink/index'
import {
login,
createContributionLink,
@ -13,17 +17,22 @@ import {
createContribution,
updateContribution,
} from '@/seeds/graphql/mutations'
import { listTransactionLinksAdmin } from '@/seeds/graphql/queries'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { User } from '@entity/User'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import Decimal from 'decimal.js-light'
import { GraphQLError } from 'graphql'
let mutate: any, con: any
let mutate: any, query: any, con: any
let testEnv: any
let user: User
beforeAll(async () => {
testEnv = await testEnvironment()
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
await cleanDB()
await userFactory(testEnv, bibiBloxberg)
@ -36,6 +45,7 @@ afterAll(async () => {
})
describe('TransactionLinkResolver', () => {
// TODO: have this test separated into a transactionLink and a contributionLink part (if possible)
describe('redeem daily Contribution Link', () => {
const now = new Date()
let contributionLink: DbContributionLink | undefined
@ -223,6 +233,274 @@ describe('TransactionLinkResolver', () => {
})
})
})
describe('transaction links list', () => {
const variables = {
userId: 1, // dummy, may be replaced
filters: null,
currentPage: 1,
pageSize: 5,
}
// TODO: there is a test not cleaning up after itself! Fix it!
beforeAll(async () => {
await cleanDB()
resetToken()
})
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
query({
query: listTransactionLinksAdmin,
variables,
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
describe('without admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
it('returns an error', async () => {
await expect(
query({
query: listTransactionLinksAdmin,
variables,
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('with admin rights', () => {
beforeAll(async () => {
// admin 'peter@lustig.de' has to exists for 'creationFactory'
await userFactory(testEnv, peterLustig)
user = await userFactory(testEnv, bibiBloxberg)
variables.userId = user.id
variables.pageSize = 25
// bibi needs GDDs
const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de')
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await creationFactory(testEnv, bibisCreation!)
// bibis transaktion links
const bibisTransaktionLinks = transactionLinks.filter(
(transactionLink) => transactionLink.email === 'bibi@bloxberg.de',
)
for (let i = 0; i < bibisTransaktionLinks.length; i++) {
await transactionLinkFactory(testEnv, bibisTransaktionLinks[i])
}
// admin: only now log in
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('without any filters', () => {
it('finds 6 open transaction links and no deleted or redeemed', async () => {
await expect(
query({
query: listTransactionLinksAdmin,
variables,
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 6,
linkList: expect.not.arrayContaining([
expect.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}),
)
})
})
describe('all filters are null', () => {
it('finds 6 open transaction links and no deleted or redeemed', async () => {
await expect(
query({
query: listTransactionLinksAdmin,
variables: {
...variables,
filters: {
withDeleted: null,
withExpired: null,
withRedeemed: null,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 6,
linkList: expect.not.arrayContaining([
expect.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}),
)
})
})
describe('filter with deleted', () => {
it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => {
await expect(
query({
query: listTransactionLinksAdmin,
variables: {
...variables,
filters: {
withDeleted: true,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 7,
linkList: expect.arrayContaining([
expect.not.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}),
)
})
})
describe('filter by expired', () => {
it('finds 5 open transaction links, 1 expired, and no redeemed', async () => {
await expect(
query({
query: listTransactionLinksAdmin,
variables: {
...variables,
filters: {
withExpired: true,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 7,
linkList: expect.arrayContaining([
expect.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.not.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}),
)
})
})
// TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory
describe.skip('filter by redeemed', () => {
it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => {
await expect(
query({
query: listTransactionLinksAdmin,
variables: {
...variables,
filters: {
withDeleted: null,
withExpired: null,
withRedeemed: true,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 6,
linkList: expect.arrayContaining([
expect.not.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.objectContaining({
memo: 'Yeah, eingelöst!',
redeemedAt: expect.any(String),
redeemedBy: expect.any(Number),
}),
expect.not.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}),
)
})
})
})
})
})
})
describe('transactionLinkCode', () => {

View File

@ -1,45 +1,36 @@
import { backendLogger as logger } from '@/server/logger'
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { getConnection } from '@dbTools/typeorm'
import {
Resolver,
Args,
Arg,
Authorized,
Ctx,
Mutation,
Query,
Int,
createUnionType,
} from 'type-graphql'
import { TransactionLink } from '@model/TransactionLink'
import { ContributionLink } from '@model/ContributionLink'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { User as dbUser } from '@entity/User'
import TransactionLinkArgs from '@arg/TransactionLinkArgs'
import Paginated from '@arg/Paginated'
import { calculateBalance } from '@/util/validate'
import { RIGHTS } from '@/auth/RIGHTS'
import { randomBytes } from 'crypto'
import Decimal from 'decimal.js-light'
import { getConnection, MoreThan, FindOperator } from '@dbTools/typeorm'
import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink'
import { User as DbUser } from '@entity/User'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { User } from '@model/User'
import { calculateDecay } from '@/util/decay'
import { executeTransaction } from './TransactionResolver'
import { ContributionLink } from '@model/ContributionLink'
import { Decay } from '@model/Decay'
import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
import { Order } from '@enum/Order'
import { ContributionType } from '@enum/ContributionType'
import { ContributionStatus } from '@enum/ContributionStatus'
import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { getUserCreation, validateContribution } from './util/creations'
import { Decay } from '@model/Decay'
import Decimal from 'decimal.js-light'
import { TransactionTypeId } from '@enum/TransactionTypeId'
import { ContributionCycleType } from '@enum/ContributionCycleType'
import TransactionLinkArgs from '@arg/TransactionLinkArgs'
import Paginated from '@arg/Paginated'
import TransactionLinkFilters from '@arg/TransactionLinkFilters'
const QueryLinkResult = createUnionType({
name: 'QueryLinkResult', // the name of the GraphQL union
types: () => [TransactionLink, ContributionLink] as const, // function that returns tuple of object types classes
})
import { backendLogger as logger } from '@/server/logger'
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { Resolver, Args, Arg, Authorized, Ctx, Mutation, Query, Int } from 'type-graphql'
import { calculateBalance } from '@/util/validate'
import { RIGHTS } from '@/auth/RIGHTS'
import { calculateDecay } from '@/util/decay'
import { getUserCreation, validateContribution } from './util/creations'
import { executeTransaction } from './TransactionResolver'
import QueryLinkResult from '@union/QueryLinkResult'
// TODO: do not export, test it inside the resolver
export const transactionLinkCode = (date: Date): string => {
@ -79,7 +70,7 @@ export class TransactionLinkResolver {
throw new Error("user hasn't enough GDD or amount is < 0")
}
const transactionLink = dbTransactionLink.create()
const transactionLink = DbTransactionLink.create()
transactionLink.userId = user.id
transactionLink.amount = amount
transactionLink.memo = memo
@ -87,7 +78,7 @@ export class TransactionLinkResolver {
transactionLink.code = transactionLinkCode(createdDate)
transactionLink.createdAt = createdDate
transactionLink.validUntil = validUntil
await dbTransactionLink.save(transactionLink).catch(() => {
await DbTransactionLink.save(transactionLink).catch(() => {
throw new Error('Unable to save transaction link')
})
@ -102,7 +93,7 @@ export class TransactionLinkResolver {
): Promise<boolean> {
const user = getUser(context)
const transactionLink = await dbTransactionLink.findOne({ id })
const transactionLink = await DbTransactionLink.findOne({ id })
if (!transactionLink) {
throw new Error('Transaction Link not found!')
}
@ -132,11 +123,11 @@ export class TransactionLinkResolver {
)
return new ContributionLink(contributionLink)
} else {
const transactionLink = await dbTransactionLink.findOneOrFail({ code }, { withDeleted: true })
const user = await dbUser.findOneOrFail({ id: transactionLink.userId })
const transactionLink = await DbTransactionLink.findOneOrFail({ code }, { withDeleted: true })
const user = await DbUser.findOneOrFail({ id: transactionLink.userId })
let redeemedBy: User | null = null
if (transactionLink && transactionLink.redeemedBy) {
redeemedBy = new User(await dbUser.findOneOrFail({ id: transactionLink.redeemedBy }))
redeemedBy = new User(await DbUser.findOneOrFail({ id: transactionLink.redeemedBy }))
}
return new TransactionLink(transactionLink, new User(user), redeemedBy)
}
@ -151,7 +142,7 @@ export class TransactionLinkResolver {
): Promise<TransactionLink[]> {
const user = getUser(context)
// const now = new Date()
const transactionLinks = await dbTransactionLink.find({
const transactionLinks = await DbTransactionLink.find({
where: {
userId: user.id,
redeemedBy: null,
@ -321,8 +312,8 @@ export class TransactionLinkResolver {
}
return true
} else {
const transactionLink = await dbTransactionLink.findOneOrFail({ code })
const linkedUser = await dbUser.findOneOrFail(
const transactionLink = await DbTransactionLink.findOneOrFail({ code })
const linkedUser = await DbUser.findOneOrFail(
{ id: transactionLink.userId },
{ relations: ['emailContact'] },
)
@ -350,4 +341,44 @@ export class TransactionLinkResolver {
return true
}
}
@Authorized([RIGHTS.LIST_TRANSACTION_LINKS_ADMIN])
@Query(() => TransactionLinkResult)
async listTransactionLinksAdmin(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Arg('filters', () => TransactionLinkFilters, { nullable: true })
filters: TransactionLinkFilters,
@Arg('userId', () => Int)
userId: number,
): Promise<TransactionLinkResult> {
const user = await DbUser.findOneOrFail({ id: userId })
const where: {
userId: number
redeemedBy?: number | null
validUntil?: FindOperator<Date> | null
} = {
userId,
redeemedBy: null,
validUntil: MoreThan(new Date()),
}
if (filters) {
if (filters.withRedeemed) delete where.redeemedBy
if (filters.withExpired) delete where.validUntil
}
const [transactionLinks, count] = await DbTransactionLink.findAndCount({
where,
withDeleted: filters ? filters.withDeleted : false,
order: {
createdAt: order,
},
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
return {
linkCount: count,
linkList: transactionLinks.map((tl) => new TransactionLink(tl, new User(user))),
}
}
}

View File

@ -1,38 +1,30 @@
/* eslint-disable new-cap */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context'
import Decimal from 'decimal.js-light'
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
import { getCustomRepository, getConnection, In } from '@dbTools/typeorm'
import { Transaction } from '@model/Transaction'
import { TransactionList } from '@model/TransactionList'
import TransactionSendArgs from '@arg/TransactionSendArgs'
import Paginated from '@arg/Paginated'
import { Order } from '@enum/Order'
import { TransactionRepository } from '@repository/Transaction'
import { TransactionLinkRepository } from '@repository/TransactionLink'
import { User as dbUser } from '@entity/User'
import { Transaction as dbTransaction } from '@entity/Transaction'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { TransactionRepository } from '@repository/Transaction'
import { TransactionLinkRepository } from '@repository/TransactionLink'
import { TransactionTypeId } from '@enum/TransactionTypeId'
import { calculateBalance, isHexPublicKey } from '@/util/validate'
import { RIGHTS } from '@/auth/RIGHTS'
import { User } from '@model/User'
import { Transaction } from '@model/Transaction'
import { TransactionList } from '@model/TransactionList'
import { Order } from '@enum/Order'
import { TransactionTypeId } from '@enum/TransactionTypeId'
import { calculateBalance } from '@/util/validate'
import TransactionSendArgs from '@arg/TransactionSendArgs'
import Paginated from '@arg/Paginated'
import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context'
import { RIGHTS } from '@/auth/RIGHTS'
import { communityUser } from '@/util/communityUser'
import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualTransactions'
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,
sendTransactionReceivedEmail,
@ -40,6 +32,10 @@ import {
import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import { BalanceResolver } from './BalanceResolver'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
import { findUserByEmail } from './UserResolver'
export const executeTransaction = async (
amount: Decimal,
memo: string,
@ -319,10 +315,6 @@ export class TransactionResolver {
// TODO this is subject to replay attacks
const senderUser = getUser(context)
if (senderUser.pubKey.length !== 32) {
logger.error(`invalid sender public key:${senderUser.pubKey}`)
throw new Error('invalid sender public key')
}
// validate recipient user
const recipientUser = await findUserByEmail(email)
@ -335,10 +327,6 @@ export class TransactionResolver {
logger.error(`The recipient account is not activated: recipientUser=${recipientUser}`)
throw new Error('The recipient account is not activated')
}
if (!isHexPublicKey(recipientUser.pubKey.toString('hex'))) {
logger.error(`invalid recipient public key: recipientUser=${recipientUser}`)
throw new Error('invalid recipient public key')
}
await executeTransaction(amount, memo, senderUser, recipientUser)
logger.info(

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { objectValuesToArray } from '@/util/utilities'
import { testEnvironment, headerPushMock, resetToken, cleanDB } from '@test/helpers'
import { logger, i18n as localization } from '@test/testSetup'
import { printTimeDuration } from '@/util/time'
@ -15,8 +16,11 @@ import {
updateUserInfos,
createContribution,
confirmContribution,
setUserRole,
deleteUser,
unDeleteUser,
} from '@/seeds/graphql/mutations'
import { verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries'
import { verifyLogin, queryOptIn, searchAdminUsers, searchUsers } from '@/seeds/graphql/queries'
import { GraphQLError } from 'graphql'
import { User } from '@entity/User'
import CONFIG from '@/config'
@ -25,7 +29,6 @@ import {
sendAccountMultiRegistrationEmail,
sendResetPasswordEmail,
} from '@/emails/sendEmailVariants'
import { activationLink } from './UserResolver'
import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
import { ContributionLink } from '@model/ContributionLink'
@ -38,6 +41,8 @@ import { UserContact } from '@entity/UserContact'
import { OptInType } from '../enum/OptInType'
import { UserContactType } from '../enum/UserContactType'
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
import { stephenHawking } from '@/seeds/users/stephen-hawking'
import { garrickOllivander } from '@/seeds/users/garrick-ollivander'
import { encryptPassword } from '@/password/PasswordEncryptor'
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
import { SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
@ -66,6 +71,8 @@ jest.mock('@/apis/KlicktippController', () => {
})
*/
let admin: User
let user: User
let mutate: any, query: any, con: any
let testEnv: any
@ -131,12 +138,8 @@ describe('UserResolver', () => {
firstName: 'Peter',
lastName: 'Lustig',
password: '0',
pubKey: null,
privKey: null,
// emailHash: expect.any(Buffer),
createdAt: expect.any(Date),
// emailChecked: false,
passphrase: expect.any(String),
language: 'de',
isAdmin: null,
deletedAt: null,
@ -807,12 +810,8 @@ describe('UserResolver', () => {
})
describe('user exists in DB', () => {
let emailContact: UserContact
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
// await resetEntity(LoginEmailOptIn)
emailContact = await UserContact.findOneOrFail(variables)
})
afterAll(async () => {
@ -821,7 +820,7 @@ describe('UserResolver', () => {
})
describe('duration not expired', () => {
it('returns true', async () => {
it('throws an error', async () => {
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
expect.objectContaining({
errors: [
@ -847,19 +846,19 @@ describe('UserResolver', () => {
}),
)
})
})
it('sends reset password email', () => {
expect(sendResetPasswordEmail).toBeCalledWith({
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
language: 'de',
resetLink: activationLink(emailContact.emailVerificationCode),
timeDurationObject: expect.objectContaining({
hours: expect.any(Number),
minutes: expect.any(Number),
}),
it('sends reset password email', () => {
expect(sendResetPasswordEmail).toBeCalledWith({
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
language: 'de',
resetLink: expect.any(String),
timeDurationObject: expect.objectContaining({
hours: expect.any(Number),
minutes: expect.any(Number),
}),
})
})
})
@ -1253,6 +1252,635 @@ describe('UserResolver', () => {
})
})
})
describe('set user role', () => {
// TODO: there is a test not cleaning up after itself! Fix it!
beforeAll(async () => {
await cleanDB()
resetToken()
})
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
mutate({ mutation: setUserRole, variables: { userId: 1, isAdmin: true } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
describe('without admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
it('returns an error', async () => {
await expect(
mutate({ mutation: setUserRole, variables: { userId: user.id + 1, isAdmin: true } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('with admin rights', () => {
beforeAll(async () => {
admin = await userFactory(testEnv, peterLustig)
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('user to get a new role does not exist', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
})
})
describe('change role with success', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
})
describe('user gets new role', () => {
describe('to admin', () => {
it('returns date string', async () => {
const result = await mutate({
mutation: setUserRole,
variables: { userId: user.id, isAdmin: true },
})
expect(result).toEqual(
expect.objectContaining({
data: {
setUserRole: expect.any(String),
},
}),
)
expect(new Date(result.data.setUserRole)).toEqual(expect.any(Date))
})
})
describe('to usual user', () => {
it('returns null', async () => {
await expect(
mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }),
).resolves.toEqual(
expect.objectContaining({
data: {
setUserRole: null,
},
}),
)
})
})
})
})
describe('change role with error', () => {
describe('is own role', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Administrator can not change his own role!')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Administrator can not change his own role!')
})
})
describe('user has already role to be set', () => {
describe('to admin', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await mutate({
mutation: setUserRole,
variables: { userId: user.id, isAdmin: true },
})
await expect(
mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: true } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('User is already admin!')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is already admin!')
})
})
describe('to usual user', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await mutate({
mutation: setUserRole,
variables: { userId: user.id, isAdmin: false },
})
await expect(
mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('User is already a usual user!')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is already a usual user!')
})
})
})
})
})
})
})
describe('delete user', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(mutate({ mutation: deleteUser, variables: { userId: 1 } })).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
describe('without admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
it('returns an error', async () => {
await expect(
mutate({ mutation: deleteUser, variables: { userId: user.id + 1 } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('with admin rights', () => {
beforeAll(async () => {
admin = await userFactory(testEnv, peterLustig)
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('user to be deleted does not exist', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
})
})
describe('delete self', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: deleteUser, variables: { userId: admin.id } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Moderator can not delete his own account!')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Moderator can not delete his own account!')
})
})
describe('delete with success', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
})
it('returns date string', async () => {
const result = await mutate({ mutation: deleteUser, variables: { userId: user.id } })
expect(result).toEqual(
expect.objectContaining({
data: {
deleteUser: expect.any(String),
},
}),
)
expect(new Date(result.data.deleteUser)).toEqual(expect.any(Date))
})
describe('delete deleted user', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: deleteUser, variables: { userId: user.id } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`Could not find user with userId: ${user.id}`)],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`)
})
})
})
})
})
})
describe('unDelete user', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(mutate({ mutation: unDeleteUser, variables: { userId: 1 } })).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
describe('without admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
it('returns an error', async () => {
await expect(
mutate({ mutation: unDeleteUser, variables: { userId: user.id + 1 } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('with admin rights', () => {
beforeAll(async () => {
admin = await userFactory(testEnv, peterLustig)
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('user to be undelete does not exist', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
})
})
describe('user to undelete is not deleted', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
})
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: unDeleteUser, variables: { userId: user.id } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('User is not deleted')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is not deleted')
})
describe('undelete deleted user', () => {
beforeAll(async () => {
await mutate({ mutation: deleteUser, variables: { userId: user.id } })
})
it('returns null', async () => {
await expect(
mutate({ mutation: unDeleteUser, variables: { userId: user.id } }),
).resolves.toEqual(
expect.objectContaining({
data: { unDeleteUser: null },
}),
)
})
})
})
})
})
})
describe('search users', () => {
const variablesWithoutTextAndFilters = {
searchText: '',
currentPage: 1,
pageSize: 25,
filters: null,
}
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
query({
query: searchUsers,
variables: {
...variablesWithoutTextAndFilters,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
describe('without admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
it('returns an error', async () => {
await expect(
query({
query: searchUsers,
variables: {
...variablesWithoutTextAndFilters,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('with admin rights', () => {
const allUsers = {
bibi: expect.objectContaining({
email: 'bibi@bloxberg.de',
}),
garrick: expect.objectContaining({
email: 'garrick@ollivander.com',
}),
peter: expect.objectContaining({
email: 'peter@lustig.de',
}),
stephen: expect.objectContaining({
email: 'stephen@hawking.uk',
}),
}
beforeAll(async () => {
admin = await userFactory(testEnv, peterLustig)
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, stephenHawking)
await userFactory(testEnv, garrickOllivander)
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('without any filters', () => {
it('finds all users', async () => {
await expect(
query({
query: searchUsers,
variables: {
...variablesWithoutTextAndFilters,
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
searchUsers: {
userCount: 4,
userList: expect.arrayContaining(objectValuesToArray(allUsers)),
},
},
}),
)
})
})
describe('all filters are null', () => {
it('finds all users', async () => {
await expect(
query({
query: searchUsers,
variables: {
...variablesWithoutTextAndFilters,
filters: {
byActivated: null,
byDeleted: null,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
searchUsers: {
userCount: 4,
userList: expect.arrayContaining(objectValuesToArray(allUsers)),
},
},
}),
)
})
})
describe('filter by unchecked email', () => {
it('finds only users with unchecked email', async () => {
await expect(
query({
query: searchUsers,
variables: {
...variablesWithoutTextAndFilters,
filters: {
byActivated: false,
byDeleted: null,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
searchUsers: {
userCount: 1,
userList: expect.arrayContaining([allUsers.garrick]),
},
},
}),
)
})
})
describe('filter by deleted users', () => {
it('finds only users with deleted account', async () => {
await expect(
query({
query: searchUsers,
variables: {
...variablesWithoutTextAndFilters,
filters: {
byActivated: null,
byDeleted: true,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
searchUsers: {
userCount: 1,
userList: expect.arrayContaining([allUsers.stephen]),
},
},
}),
)
})
})
describe('filter by deleted account and unchecked email', () => {
it('finds no users', async () => {
await expect(
query({
query: searchUsers,
variables: {
...variablesWithoutTextAndFilters,
filters: {
byActivated: false,
byDeleted: true,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
searchUsers: {
userCount: 0,
userList: [],
},
},
}),
)
})
})
})
})
})
})
describe('printTimeDuration', () => {

View File

@ -1,28 +1,50 @@
import fs from 'fs'
import { backendLogger as logger } from '@/server/logger'
import i18n from 'i18n'
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql'
import { v4 as uuidv4 } from 'uuid'
import {
Resolver,
Query,
Args,
Arg,
Authorized,
Ctx,
UseMiddleware,
Mutation,
Int,
} 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 { getTimeDurationObject, printTimeDuration } from '@/util/time'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { ContributionLink as dbContributionLink } from '@entity/ContributionLink'
import { encode } from '@/auth/JWT'
import CreateUserArgs from '@arg/CreateUserArgs'
import UnsecureLoginArgs from '@arg/UnsecureLoginArgs'
import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { UserRepository } from '@repository/User'
import { User } from '@model/User'
import { SearchAdminUsersResult } from '@model/AdminUser'
import { UserAdmin, SearchUsersResult } from '@model/UserAdmin'
import { OptInType } from '@enum/OptInType'
import { Order } from '@enum/Order'
import { UserContactType } from '@enum/UserContactType'
import {
sendAccountActivationEmail,
sendAccountMultiRegistrationEmail,
sendResetPasswordEmail,
} from '@/emails/sendEmailVariants'
import { getTimeDurationObject, printTimeDuration } from '@/util/time'
import CreateUserArgs from '@arg/CreateUserArgs'
import UnsecureLoginArgs from '@arg/UnsecureLoginArgs'
import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
import Paginated from '@arg/Paginated'
import SearchUsersArgs from '@arg/SearchUsersArgs'
import { backendLogger as logger } from '@/server/logger'
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import CONFIG from '@/config'
import { communityDbUser } from '@/util/communityUser'
import { encode } from '@/auth/JWT'
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
import { klicktippSignIn } from '@/apis/KlicktippController'
import { RIGHTS } from '@/auth/RIGHTS'
import { hasElopageBuys } from '@/util/hasElopageBuys'
@ -36,14 +58,9 @@ import {
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'
import { Order } from '@enum/Order'
import { v4 as uuidv4 } from 'uuid'
import { isValidPassword, SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
import { getUserCreation, getUserCreations } from './util/creations'
import { isValidPassword } from '@/password/EncryptorUtils'
import { FULL_CREATION_AVAILABLE } from './const/const'
import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor'
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
@ -58,89 +75,6 @@ const isLanguage = (language: string): boolean => {
return LANGUAGES.includes(language)
}
const PHRASE_WORD_COUNT = 24
const WORDS = fs
.readFileSync('src/config/mnemonic.uncompressed_buffer13116.txt')
.toString()
.split(',')
const PassphraseGenerate = (): string[] => {
logger.trace('PassphraseGenerate...')
const result = []
for (let i = 0; i < PHRASE_WORD_COUNT; i++) {
result.push(WORDS[sodium.randombytes_random() % 2048])
}
return result
}
const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => {
logger.trace('KeyPairEd25519Create...')
if (!passphrase.length || passphrase.length < PHRASE_WORD_COUNT) {
logger.error('passphrase empty or to short')
throw new Error('passphrase empty or to short')
}
const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES)
sodium.crypto_hash_sha512_init(state)
// To prevent breaking existing passphrase-hash combinations word indices will be put into 64 Bit Variable to mimic first implementation of algorithms
for (let i = 0; i < PHRASE_WORD_COUNT; i++) {
const value = Buffer.alloc(8)
const wordIndex = WORDS.indexOf(passphrase[i])
value.writeBigInt64LE(BigInt(wordIndex))
sodium.crypto_hash_sha512_update(state, value)
}
// trailing space is part of the login_server implementation
const clearPassphrase = passphrase.join(' ') + ' '
sodium.crypto_hash_sha512_update(state, Buffer.from(clearPassphrase))
const outputHashBuffer = Buffer.alloc(sodium.crypto_hash_sha512_BYTES)
sodium.crypto_hash_sha512_final(state, outputHashBuffer)
const pubKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
const privKey = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES)
sodium.crypto_sign_seed_keypair(
pubKey,
privKey,
outputHashBuffer.slice(0, sodium.crypto_sign_SEEDBYTES),
)
logger.debug(`KeyPair creation ready. pubKey=${pubKey}`)
return [pubKey, privKey]
}
/*
const getEmailHash = (email: string): Buffer => {
logger.trace('getEmailHash...')
const emailHash = Buffer.alloc(sodium.crypto_generichash_BYTES)
sodium.crypto_generichash(emailHash, Buffer.from(email))
logger.debug(`getEmailHash...successful: ${emailHash}`)
return emailHash
}
*/
const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => {
logger.trace('SecretKeyCryptographyEncrypt...')
const encrypted = Buffer.alloc(message.length + sodium.crypto_secretbox_MACBYTES)
const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES)
nonce.fill(31) // static nonce
sodium.crypto_secretbox_easy(encrypted, message, nonce, encryptionKey)
logger.debug(`SecretKeyCryptographyEncrypt...successful: ${encrypted}`)
return encrypted
}
const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: Buffer): Buffer => {
logger.trace('SecretKeyCryptographyDecrypt...')
const message = Buffer.alloc(encryptedMessage.length - sodium.crypto_secretbox_MACBYTES)
const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES)
nonce.fill(31) // static nonce
sodium.crypto_secretbox_open_easy(message, encryptedMessage, nonce, encryptionKey)
logger.debug(`SecretKeyCryptographyDecrypt...successful: ${message}`)
return message
}
const newEmailContact = (email: string, userId: number): DbUserContact => {
logger.trace(`newEmailContact...`)
const emailContact = new DbUserContact()
@ -153,91 +87,6 @@ const newEmailContact = (email: string, userId: number): DbUserContact => {
logger.debug(`newEmailContact...successful: ${emailContact}`)
return emailContact
}
/*
const newEmailOptIn = (userId: number): LoginEmailOptIn => {
logger.trace('newEmailOptIn...')
const emailOptIn = new LoginEmailOptIn()
emailOptIn.verificationCode = random(64)
emailOptIn.userId = userId
emailOptIn.emailOptInTypeId = OptInType.EMAIL_OPT_IN_REGISTER
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
export const checkOptInCode = async (
optInCode: LoginEmailOptIn | undefined,
user: DbUser,
optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
): Promise<LoginEmailOptIn> => {
logger.info(`checkOptInCode... ${optInCode}`)
if (optInCode) {
if (!canResendOptIn(optInCode)) {
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`,
)
}
optInCode.updatedAt = new Date()
optInCode.resendCount++
} else {
logger.trace('create new OptIn for userId=' + user.id)
optInCode = newEmailOptIn(user.id)
}
if (user.emailChecked) {
optInCode.emailOptInTypeId = optInType
}
await LoginEmailOptIn.save(optInCode).catch(() => {
logger.error('Unable to save optin code= ' + optInCode)
throw new Error('Unable to save optin code.')
})
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<DbUserContact> => {
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 = (verificationCode: BigInt): string => {
logger.debug(`activationLink(${verificationCode})...`)
@ -268,7 +117,6 @@ export class UserResolver {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const userEntity = getUser(context)
const user = new User(userEntity, await getUserCreation(userEntity.id, clientTimezoneOffset))
// user.pubkey = userEntity.pubKey.toString('hex')
// Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage(context)
@ -300,11 +148,6 @@ export class UserResolver {
// TODO we want to catch this on the frontend and ask the user to check his emails or resend code
throw new Error('User has no password set yet')
}
if (!dbUser.pubKey || !dbUser.privKey) {
logger.error('The User has no private or publicKey.')
// TODO we want to catch this on the frontend and ask the user to check his emails or resend code
throw new Error('User has no private or publicKey')
}
if (!verifyPassword(dbUser, password)) {
logger.error('The User has no valid credentials.')
@ -336,7 +179,7 @@ export class UserResolver {
context.setHeaders.push({
key: 'token',
value: encode(dbUser.pubKey),
value: encode(dbUser.gradidoID),
})
const ev = new EventLogin()
ev.userId = user.id
@ -348,6 +191,7 @@ export class UserResolver {
@Authorized([RIGHTS.LOGOUT])
@Mutation(() => String)
async logout(): Promise<boolean> {
// TODO: Event still missing here!!
// TODO: We dont need this anymore, but might need this in the future in oder to invalidate a valid JWT-Token.
// Furthermore this hook can be useful for tracking user behaviour (did he logout or not? Warn him if he didn't on next login)
// The functionality is fully client side - the client just needs to delete his token with the current implementation.
@ -428,11 +272,6 @@ export class UserResolver {
}
}
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 gradidoID = await newGradidoID()
const eventRegister = new EventRegister()
@ -446,11 +285,10 @@ export class UserResolver {
dbUser.language = language
dbUser.publisherId = publisherId
dbUser.passwordEncryptionType = PasswordEncryptionType.NO_PASSWORD
dbUser.passphrase = passphrase.join(' ')
logger.debug('new dbUser=' + dbUser)
if (redeemCode) {
if (redeemCode.match(/^CL-/)) {
const contributionLink = await dbContributionLink.findOne({
const contributionLink = await DbContributionLink.findOne({
code: redeemCode.replace('CL-', ''),
})
logger.info('redeemCode found contributionLink=' + contributionLink)
@ -459,7 +297,7 @@ export class UserResolver {
eventRedeemRegister.contributionId = contributionLink.id
}
} else {
const transactionLink = await dbTransactionLink.findOne({ code: redeemCode })
const transactionLink = await DbTransactionLink.findOne({ code: redeemCode })
logger.info('redeemCode found transactionLink=' + transactionLink)
if (transactionLink) {
dbUser.referrerId = transactionLink.userId
@ -467,12 +305,6 @@ export class UserResolver {
}
}
}
// TODO this field has no null allowed unlike the loginServer table
// dbUser.pubKey = Buffer.from(randomBytes(32)) // Buffer.alloc(32, 0) default to 0000...
// dbUser.pubkey = keyPair[0]
// loginUser.password = passwordHash[0].readBigUInt64LE() // using the shorthash
// loginUser.pubKey = keyPair[0]
// loginUser.privKey = encryptedPrivkey
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
@ -561,32 +393,45 @@ export class UserResolver {
return true
}
// can be both types: REGISTER and RESET_PASSWORD
// 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,
)
if (!canEmailResend(user.emailContact.updatedAt || user.emailContact.createdAt)) {
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`,
)
}
// optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD)
logger.info(`optInCode for ${email}=${dbUserContact}`)
user.emailContact.updatedAt = new Date()
user.emailContact.emailResendCount++
user.emailContact.emailVerificationCode = random(64)
user.emailContact.emailOptInTypeId = OptInType.EMAIL_OPT_IN_RESET_PASSWORD
await user.emailContact.save().catch(() => {
logger.error('Unable to save email verification code= ' + user.emailContact)
throw new Error('Unable to save email verification code.')
})
logger.info(`optInCode for ${email}=${user.emailContact}`)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendResetPasswordEmail({
firstName: user.firstName,
lastName: user.lastName,
email,
language: user.language,
resetLink: activationLink(dbUserContact.emailVerificationCode),
resetLink: activationLink(user.emailContact.emailVerificationCode),
timeDurationObject: getTimeDurationObject(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) {
logger.debug(`Reset password link: ${activationLink(dbUserContact.emailVerificationCode)}`)
logger.debug(
`Reset password link: ${activationLink(user.emailContact.emailVerificationCode)}`,
)
}
logger.info(`forgotPassword(${email}) successful...`)
@ -624,7 +469,7 @@ export class UserResolver {
})
logger.debug('userContact loaded...')
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) {
logger.error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
@ -638,34 +483,12 @@ export class UserResolver {
const user = userContact.user
logger.debug('user with EmailVerificationCode found...')
// Generate Passphrase if needed
if (!user.passphrase) {
const passphrase = PassphraseGenerate()
user.passphrase = passphrase.join(' ')
logger.debug('new Passphrase generated...')
}
const passphrase = user.passphrase.split(' ')
if (passphrase.length < PHRASE_WORD_COUNT) {
logger.error('Could not load a correct passphrase')
// TODO if this can happen we cannot recover from that
// this seem to be good on production data, if we dont
// make a coding mistake we do not have a problem here
throw new Error('Could not load a correct passphrase')
}
logger.debug('Passphrase is valid...')
// Activate EMail
userContact.emailChecked = true
// Update Password
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
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 = encryptPassword(user, password)
user.pubKey = keyPair[0]
user.privKey = encryptedPrivkey
logger.debug('User credentials updated ...')
const queryRunner = getConnection().createQueryRunner()
@ -728,7 +551,7 @@ export class UserResolver {
const userContact = await DbUserContact.findOneOrFail({ emailVerificationCode: optIn })
logger.debug(`found optInCode=${userContact}`)
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) {
logger.error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
@ -776,30 +599,14 @@ export class UserResolver {
)
}
// TODO: This had some error cases defined - like missing private key. This is no longer checked.
const oldPasswordHash = SecretKeyCryptographyCreateKey(
userEntity.emailContact.email,
password,
)
if (!verifyPassword(userEntity, password)) {
logger.error(`Old password is invalid`)
throw new Error(`Old password is invalid`)
}
const privKey = SecretKeyCryptographyDecrypt(userEntity.privKey, oldPasswordHash[1])
logger.debug('oldPassword decrypted...')
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...')
// Save new password hash and newly encrypted private key
userEntity.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
userEntity.password = encryptPassword(userEntity, passwordNew)
userEntity.privKey = encryptedPrivkey
}
const queryRunner = getConnection().createQueryRunner()
@ -864,6 +671,206 @@ export class UserResolver {
}),
}
}
@Authorized([RIGHTS.SEARCH_USERS])
@Query(() => SearchUsersResult)
async searchUsers(
@Args()
{ searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs,
@Ctx() context: Context,
): Promise<SearchUsersResult> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const userRepository = getCustomRepository(UserRepository)
const userFields = [
'id',
'firstName',
'lastName',
'emailId',
'emailContact',
'deletedAt',
'isAdmin',
]
const [users, count] = await userRepository.findBySearchCriteriaPagedFiltered(
userFields.map((fieldName) => {
return 'user.' + fieldName
}),
searchText,
filters,
currentPage,
pageSize,
)
if (users.length === 0) {
return {
userCount: 0,
userList: [],
}
}
const creations = await getUserCreations(
users.map((u) => u.id),
clientTimezoneOffset,
)
const adminUsers = await Promise.all(
users.map(async (user) => {
let emailConfirmationSend = ''
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.emailContact.email),
emailConfirmationSend,
)
return adminUser
}),
)
return {
userCount: count,
userList: adminUsers,
}
}
@Authorized([RIGHTS.SET_USER_ROLE])
@Mutation(() => Date, { nullable: true })
async setUserRole(
@Arg('userId', () => Int)
userId: number,
@Arg('isAdmin', () => Boolean)
isAdmin: boolean,
@Ctx()
context: Context,
): Promise<Date | null> {
const user = await DbUser.findOne({ id: userId })
// user exists ?
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
}
// administrator user changes own role?
const moderatorUser = getUser(context)
if (moderatorUser.id === userId) {
logger.error('Administrator can not change his own role!')
throw new Error('Administrator can not change his own role!')
}
// change isAdmin
switch (user.isAdmin) {
case null:
if (isAdmin === true) {
user.isAdmin = new Date()
} else {
logger.error('User is already a usual user!')
throw new Error('User is already a usual user!')
}
break
default:
if (isAdmin === false) {
user.isAdmin = null
} else {
logger.error('User is already admin!')
throw new Error('User is already admin!')
}
break
}
await user.save()
const newUser = await DbUser.findOne({ id: userId })
return newUser ? newUser.isAdmin : null
}
@Authorized([RIGHTS.DELETE_USER])
@Mutation(() => Date, { nullable: true })
async deleteUser(
@Arg('userId', () => Int) userId: number,
@Ctx() context: Context,
): Promise<Date | null> {
const user = await DbUser.findOne({ id: userId })
// user exists ?
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
}
// moderator user disabled own account?
const moderatorUser = getUser(context)
if (moderatorUser.id === userId) {
logger.error('Moderator can not delete his own account!')
throw new Error('Moderator can not delete his own account!')
}
// soft-delete user
await user.softRemove()
const newUser = await DbUser.findOne({ id: userId }, { withDeleted: true })
return newUser ? newUser.deletedAt : null
}
@Authorized([RIGHTS.UNDELETE_USER])
@Mutation(() => Date, { nullable: true })
async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise<Date | null> {
const user = await DbUser.findOne({ id: userId }, { withDeleted: true })
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
}
if (!user.deletedAt) {
logger.error('User is not deleted')
throw new Error('User is not deleted')
}
await user.recover()
return null
}
@Authorized([RIGHTS.SEND_ACTIVATION_EMAIL])
@Mutation(() => Boolean)
async sendActivationEmail(@Arg('email') email: string): Promise<boolean> {
email = email.trim().toLowerCase()
// 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 this User is deleted.`)
throw new Error(`The emailContact: ${email} of this User is deleted.`)
}
emailContact.emailResendCount++
await emailContact.save()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountActivationEmail({
firstName: user.firstName,
lastName: user.lastName,
email,
language: user.language,
activationLink: activationLink(emailContact.emailVerificationCode),
timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME),
})
// In case EMails are disabled log the activation link for the user
if (!emailSent) {
logger.info(`Account confirmation link: ${activationLink}`)
} else {
const event = new Event()
const eventSendConfirmationEmail = new EventSendConfirmationEmail()
eventSendConfirmationEmail.userId = user.id
await eventProtocol.writeEvent(
event.setEventSendConfirmationEmail(eventSendConfirmationEmail),
)
}
return true
}
}
export async function findUserByEmail(email: string): Promise<DbUser> {
@ -904,10 +911,7 @@ const isOptInValid = (optIn: LoginEmailOptIn): boolean => {
return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME)
}
*/
const isEmailVerificationCodeValid = (updatedAt: Date | null): boolean => {
if (updatedAt == null) {
return true
}
const isEmailVerificationCodeValid = (updatedAt: Date): boolean => {
return isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_VALID_TIME)
}
/*

View File

@ -0,0 +1,7 @@
import { createUnionType } from 'type-graphql'
import { TransactionLink } from '@model/TransactionLink'
import { ContributionLink } from '@model/ContributionLink'
export default createUnionType({
name: 'QueryLinkResult', // the name of the GraphQL union
types: () => [TransactionLink, ContributionLink] as const, // function that returns tuple of object types classes
})

View File

@ -4,21 +4,6 @@ import { User as DbUser } from '@entity/User'
@EntityRepository(DbUser)
export class UserRepository extends Repository<DbUser> {
async findByPubkeyHex(pubkeyHex: string): Promise<DbUser> {
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,

View File

@ -16,8 +16,6 @@ const communityDbUser: dbUser = {
emailId: -1,
firstName: 'Gradido',
lastName: 'Akademie',
pubKey: Buffer.from(''),
privKey: Buffer.from(''),
deletedAt: null,
password: BigInt(0),
// emailHash: Buffer.from(''),
@ -26,7 +24,6 @@ const communityDbUser: dbUser = {
language: '',
isAdmin: null,
publisherId: 0,
passphrase: '',
// default password encryption type
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
hasId: function (): boolean {

View File

@ -14,10 +14,6 @@ function isStringBoolean(value: string): boolean {
return false
}
function isHexPublicKey(publicKey: string): boolean {
return /^[0-9A-Fa-f]{64}$/i.test(publicKey)
}
async function calculateBalance(
userId: number,
amount: Decimal,
@ -45,4 +41,4 @@ async function calculateBalance(
return { balance, lastTransactionId: lastTransaction.id, decay }
}
export { isHexPublicKey, calculateBalance, isStringBoolean }
export { calculateBalance, isStringBoolean }

View File

@ -0,0 +1,7 @@
import { contributionDateFormatter } from '@test/helpers'
describe('contributionDateFormatter', () => {
it('formats the date correctly', () => {
expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024')
})
})

View File

@ -5,6 +5,7 @@ import { createTestClient } from 'apollo-server-testing'
import createServer from '../src/server/createServer'
import { initialize } from '@dbTools/helpers'
import { entities } from '@entity/index'
import { i18n, logger } from './testSetup'
export const headerPushMock = jest.fn((t) => {
context.token = t.value
@ -26,8 +27,8 @@ export const cleanDB = async () => {
}
}
export const testEnvironment = async (logger?: any, localization?: any) => {
const server = await createServer(context, logger, localization)
export const testEnvironment = async (testLogger: any = logger, testI18n: any = i18n) => {
const server = await createServer(context, testLogger, testI18n)
const con = server.con
const testClient = createTestClient(server.apollo)
const mutate = testClient.mutate

View File

@ -51,6 +51,7 @@
"@arg/*": ["src/graphql/arg/*"],
"@enum/*": ["src/graphql/enum/*"],
"@model/*": ["src/graphql/model/*"],
"@union/*": ["src/graphql/union/*"],
"@repository/*": ["src/typeorm/repository/*"],
"@test/*": ["test/*"],
/* external */

View File

@ -0,0 +1,112 @@
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
@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
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false })
createdAt: Date
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt: Date | null
@Column({ type: 'bigint', default: 0, unsigned: true })
password: BigInt
@Column({
name: 'password_encryption_type',
type: 'int',
unsigned: true,
nullable: false,
default: 0,
})
passwordEncryptionType: number
@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
@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[]
}

View File

@ -0,0 +1,57 @@
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_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
}

View File

@ -1 +1 @@
export { User } from './0053-change_password_encryption/User'
export { User } from './0057-clear_old_password_junk/User'

View File

@ -1 +1 @@
export { UserContact } from './0053-change_password_encryption/UserContact'
export { UserContact } from './0057-clear_old_password_junk/UserContact'

View File

@ -0,0 +1,39 @@
/* MIGRATION TO add users that have a transaction but do not exist */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { v4 as uuidv4 } from 'uuid'
import { OkPacket } from 'mysql'
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
const missingUserIds = await queryFn(`
SELECT user_id FROM transactions
WHERE NOT EXISTS (SELECT id FROM users WHERE id = user_id) GROUP BY user_id;`)
for (let i = 0; i < missingUserIds.length; i++) {
let gradidoId = ''
let countIds: any[] = []
do {
gradidoId = uuidv4()
countIds = await queryFn(
`SELECT COUNT(*) FROM \`users\` WHERE \`gradido_id\` = "${gradidoId}"`,
)
} while (countIds[0] > 0)
const userContact = (await queryFn(`
INSERT INTO user_contacts
(type, user_id, email, email_checked, created_at, deleted_at)
VALUES
('EMAIL', ${missingUserIds[i].user_id}, 'deleted.user${missingUserIds[i].user_id}@gradido.net', 0, NOW(), NOW());`)) as unknown as OkPacket
await queryFn(`
INSERT INTO users
(id, gradido_id, email_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language)
VALUES
(${missingUserIds[i].user_id}, '${gradidoId}', ${userContact.insertId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`)
}
}
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {}

View File

@ -0,0 +1,16 @@
/* 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<Array<any>>) {
await queryFn('ALTER TABLE users DROP COLUMN public_key;')
await queryFn('ALTER TABLE users DROP COLUMN privkey;')
await queryFn('ALTER TABLE users DROP COLUMN email_hash;')
await queryFn('ALTER TABLE users DROP COLUMN passphrase;')
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE users ADD COLUMN public_key binary(32) DEFAULT NULL;')
await queryFn('ALTER TABLE users ADD COLUMN privkey binary(80) DEFAULT NULL;')
await queryFn('ALTER TABLE users ADD COLUMN email_hash binary(32) DEFAULT NULL;')
await queryFn('ALTER TABLE users ADD COLUMN passphrase text DEFAULT NULL;')
}

View File

@ -1,6 +1,6 @@
{
"name": "gradido-database",
"version": "1.15.0",
"version": "1.16.0",
"description": "Gradido Database Tool to execute database migrations",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/database",

View File

@ -1,6 +1,6 @@
{
"name": "bootstrap-vue-gradido-wallet",
"version": "1.15.0",
"version": "1.16.0",
"private": true,
"scripts": {
"start": "node run/server.js",

View File

@ -1,6 +1,6 @@
{
"name": "gradido",
"version": "1.15.0",
"version": "1.16.0",
"description": "Gradido",
"main": "index.js",
"repository": "git@github.com:gradido/gradido.git",