mirror of
https://github.com/IT4Change/gradido.git
synced 2025-12-13 07:45:54 +00:00
Merge dissolve_admin_resolver to local branch.
This commit is contained in:
commit
10abf45e15
@ -9,9 +9,10 @@ module.exports = {
|
|||||||
modulePathIgnorePatterns: ['<rootDir>/build/'],
|
modulePathIgnorePatterns: ['<rootDir>/build/'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'@/(.*)': '<rootDir>/src/$1',
|
'@/(.*)': '<rootDir>/src/$1',
|
||||||
'@model/(.*)': '<rootDir>/src/graphql/model/$1',
|
|
||||||
'@arg/(.*)': '<rootDir>/src/graphql/arg/$1',
|
'@arg/(.*)': '<rootDir>/src/graphql/arg/$1',
|
||||||
'@enum/(.*)': '<rootDir>/src/graphql/enum/$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',
|
'@repository/(.*)': '<rootDir>/src/typeorm/repository/$1',
|
||||||
'@test/(.*)': '<rootDir>/test/$1',
|
'@test/(.*)': '<rootDir>/test/$1',
|
||||||
'@entity/(.*)':
|
'@entity/(.*)':
|
||||||
|
|||||||
@ -53,4 +53,5 @@ export enum RIGHTS {
|
|||||||
DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK',
|
DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK',
|
||||||
UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK',
|
UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK',
|
||||||
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE',
|
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE',
|
||||||
|
REJECT_CONTRIBUTION = 'REJECT_CONTRIBUTION',
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +1,19 @@
|
|||||||
import { backendLogger as logger } from '@/server/logger'
|
import Decimal from 'decimal.js-light'
|
||||||
|
|
||||||
import { Context, getUser } from '@/server/context'
|
|
||||||
import { Resolver, Query, Ctx, Authorized } from 'type-graphql'
|
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 { Balance } from '@model/Balance'
|
||||||
|
|
||||||
|
import { backendLogger as logger } from '@/server/logger'
|
||||||
|
import { Context, getUser } from '@/server/context'
|
||||||
import { calculateDecay } from '@/util/decay'
|
import { calculateDecay } from '@/util/decay'
|
||||||
import { RIGHTS } from '@/auth/RIGHTS'
|
import { RIGHTS } from '@/auth/RIGHTS'
|
||||||
import { Transaction as dbTransaction } from '@entity/Transaction'
|
|
||||||
import Decimal from 'decimal.js-light'
|
|
||||||
import { GdtResolver } from './GdtResolver'
|
import { GdtResolver } from './GdtResolver'
|
||||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
|
||||||
import { getCustomRepository } from '@dbTools/typeorm'
|
|
||||||
import { TransactionLinkRepository } from '@repository/TransactionLink'
|
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
export class BalanceResolver {
|
export class BalanceResolver {
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { Resolver, Query, Authorized } from 'type-graphql'
|
import { Resolver, Query, Authorized } from 'type-graphql'
|
||||||
|
|
||||||
|
import { Community } from '@model/Community'
|
||||||
|
|
||||||
import { RIGHTS } from '@/auth/RIGHTS'
|
import { RIGHTS } from '@/auth/RIGHTS'
|
||||||
import CONFIG from '@/config'
|
import CONFIG from '@/config'
|
||||||
import { Community } from '@model/Community'
|
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
export class CommunityResolver {
|
export class CommunityResolver {
|
||||||
|
|||||||
649
backend/src/graphql/resolver/ContributionLinkResolver.test.ts
Normal file
649
backend/src/graphql/resolver/ContributionLinkResolver.test.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
152
backend/src/graphql/resolver/ContributionLinkResolver.ts
Normal file
152
backend/src/graphql/resolver/ContributionLinkResolver.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 { backendLogger as logger } from '@/server/logger'
|
||||||
import { RIGHTS } from '@/auth/RIGHTS'
|
import { RIGHTS } from '@/auth/RIGHTS'
|
||||||
import { Context, getUser } from '@/server/context'
|
import { Context, getUser } from '@/server/context'
|
||||||
import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage'
|
import { sendAddedContributionMessageEmail } from '@/emails/sendEmailVariants'
|
||||||
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'
|
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
export class ContributionMessageResolver {
|
export class ContributionMessageResolver {
|
||||||
@ -26,7 +31,7 @@ export class ContributionMessageResolver {
|
|||||||
await queryRunner.startTransaction('REPEATABLE READ')
|
await queryRunner.startTransaction('REPEATABLE READ')
|
||||||
const contributionMessage = DbContributionMessage.create()
|
const contributionMessage = DbContributionMessage.create()
|
||||||
try {
|
try {
|
||||||
const contribution = await Contribution.findOne({ id: contributionId })
|
const contribution = await DbContribution.findOne({ id: contributionId })
|
||||||
if (!contribution) {
|
if (!contribution) {
|
||||||
throw new Error('Contribution not found')
|
throw new Error('Contribution not found')
|
||||||
}
|
}
|
||||||
@ -44,7 +49,7 @@ export class ContributionMessageResolver {
|
|||||||
|
|
||||||
if (contribution.contributionStatus === ContributionStatus.IN_PROGRESS) {
|
if (contribution.contributionStatus === ContributionStatus.IN_PROGRESS) {
|
||||||
contribution.contributionStatus = ContributionStatus.PENDING
|
contribution.contributionStatus = ContributionStatus.PENDING
|
||||||
await queryRunner.manager.update(Contribution, { id: contributionId }, contribution)
|
await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution)
|
||||||
}
|
}
|
||||||
await queryRunner.commitTransaction()
|
await queryRunner.commitTransaction()
|
||||||
} catch (e) {
|
} 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
@ -1,27 +1,55 @@
|
|||||||
import { RIGHTS } from '@/auth/RIGHTS'
|
import Decimal from 'decimal.js-light'
|
||||||
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
|
|
||||||
import { backendLogger as logger } from '@/server/logger'
|
|
||||||
import { Contribution as dbContribution } from '@entity/Contribution'
|
|
||||||
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
|
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
|
||||||
import { FindOperator, IsNull, getConnection } from '@dbTools/typeorm'
|
import { FindOperator, IsNull, In, getConnection } from '@dbTools/typeorm'
|
||||||
import ContributionArgs from '@arg/ContributionArgs'
|
|
||||||
import Paginated from '@arg/Paginated'
|
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 { Order } from '@enum/Order'
|
||||||
import { ContributionType } from '@enum/ContributionType'
|
import { ContributionType } from '@enum/ContributionType'
|
||||||
import { ContributionStatus } from '@enum/ContributionStatus'
|
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 { 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 {
|
import {
|
||||||
Event,
|
Event,
|
||||||
EventContributionCreate,
|
EventContributionCreate,
|
||||||
EventContributionDelete,
|
EventContributionDelete,
|
||||||
EventContributionUpdate,
|
EventContributionUpdate,
|
||||||
|
EventContributionConfirm,
|
||||||
|
EventAdminContributionCreate,
|
||||||
|
EventAdminContributionDelete,
|
||||||
|
EventAdminContributionUpdate,
|
||||||
} from '@/event/Event'
|
} from '@/event/Event'
|
||||||
import { eventProtocol } from '@/event/EventProtocolEmitter'
|
import { eventProtocol } from '@/event/EventProtocolEmitter'
|
||||||
|
import { calculateDecay } from '@/util/decay'
|
||||||
|
import {
|
||||||
|
sendContributionConfirmedEmail,
|
||||||
|
sendContributionRejectedEmail,
|
||||||
|
} from '@/emails/sendEmailVariants'
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
export class ContributionResolver {
|
export class ContributionResolver {
|
||||||
@ -50,7 +78,7 @@ export class ContributionResolver {
|
|||||||
const creationDateObj = new Date(creationDate)
|
const creationDateObj = new Date(creationDate)
|
||||||
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
|
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
|
||||||
|
|
||||||
const contribution = dbContribution.create()
|
const contribution = DbContribution.create()
|
||||||
contribution.userId = user.id
|
contribution.userId = user.id
|
||||||
contribution.amount = amount
|
contribution.amount = amount
|
||||||
contribution.createdAt = new Date()
|
contribution.createdAt = new Date()
|
||||||
@ -60,7 +88,7 @@ export class ContributionResolver {
|
|||||||
contribution.contributionStatus = ContributionStatus.PENDING
|
contribution.contributionStatus = ContributionStatus.PENDING
|
||||||
|
|
||||||
logger.trace('contribution to save', contribution)
|
logger.trace('contribution to save', contribution)
|
||||||
await dbContribution.save(contribution)
|
await DbContribution.save(contribution)
|
||||||
|
|
||||||
const eventCreateContribution = new EventContributionCreate()
|
const eventCreateContribution = new EventContributionCreate()
|
||||||
eventCreateContribution.userId = user.id
|
eventCreateContribution.userId = user.id
|
||||||
@ -79,7 +107,7 @@ export class ContributionResolver {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const event = new Event()
|
const event = new Event()
|
||||||
const user = getUser(context)
|
const user = getUser(context)
|
||||||
const contribution = await dbContribution.findOne(id)
|
const contribution = await DbContribution.findOne(id)
|
||||||
if (!contribution) {
|
if (!contribution) {
|
||||||
logger.error('Contribution not found for given id')
|
logger.error('Contribution not found for given id')
|
||||||
throw new 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()
|
const [contributions, count] = await getConnection()
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.select('c')
|
.select('c')
|
||||||
.from(dbContribution, 'c')
|
.from(DbContribution, 'c')
|
||||||
.leftJoinAndSelect('c.messages', 'm')
|
.leftJoinAndSelect('c.messages', 'm')
|
||||||
.where(where)
|
.where(where)
|
||||||
.withDeleted()
|
.withDeleted()
|
||||||
@ -152,7 +180,7 @@ export class ContributionResolver {
|
|||||||
const [dbContributions, count] = await getConnection()
|
const [dbContributions, count] = await getConnection()
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.select('c')
|
.select('c')
|
||||||
.from(dbContribution, 'c')
|
.from(DbContribution, 'c')
|
||||||
.innerJoinAndSelect('c.user', 'u')
|
.innerJoinAndSelect('c.user', 'u')
|
||||||
.orderBy('c.createdAt', order)
|
.orderBy('c.createdAt', order)
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
@ -185,7 +213,7 @@ export class ContributionResolver {
|
|||||||
|
|
||||||
const user = getUser(context)
|
const user = getUser(context)
|
||||||
|
|
||||||
const contributionToUpdate = await dbContribution.findOne({
|
const contributionToUpdate = await DbContribution.findOne({
|
||||||
where: { id: contributionId, confirmedAt: IsNull() },
|
where: { id: contributionId, confirmedAt: IsNull() },
|
||||||
})
|
})
|
||||||
if (!contributionToUpdate) {
|
if (!contributionToUpdate) {
|
||||||
@ -240,7 +268,7 @@ export class ContributionResolver {
|
|||||||
contributionToUpdate.contributionDate = new Date(creationDate)
|
contributionToUpdate.contributionDate = new Date(creationDate)
|
||||||
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
|
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
|
||||||
contributionToUpdate.updatedAt = new Date()
|
contributionToUpdate.updatedAt = new Date()
|
||||||
dbContribution.save(contributionToUpdate)
|
DbContribution.save(contributionToUpdate)
|
||||||
|
|
||||||
const event = new Event()
|
const event = new Event()
|
||||||
|
|
||||||
@ -253,6 +281,405 @@ export class ContributionResolver {
|
|||||||
return new UnconfirmedContribution(contributionToUpdate, user, creations)
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
@Authorized([RIGHTS.REJECT_CONTRIBUTION])
|
@Authorized([RIGHTS.REJECT_CONTRIBUTION])
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
async rejectContribution(
|
async rejectContribution(
|
||||||
@ -261,7 +688,7 @@ export class ContributionResolver {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const user = getUser(context)
|
const user = getUser(context)
|
||||||
|
|
||||||
const contributionToUpdate = await dbContribution.findOne({ id })
|
const contributionToUpdate = await DbContribution.findOne({ id })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
126
backend/src/graphql/resolver/EmailOptinCodes.test.ts
Normal file
126
backend/src/graphql/resolver/EmailOptinCodes.test.ts
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import { Context, getUser } from '@/server/context'
|
|
||||||
import { Resolver, Query, Args, Ctx, Authorized, Arg } from 'type-graphql'
|
import { Resolver, Query, Args, Ctx, Authorized, Arg } from 'type-graphql'
|
||||||
import CONFIG from '@/config'
|
|
||||||
import { GdtEntryList } from '@model/GdtEntryList'
|
import { GdtEntryList } from '@model/GdtEntryList'
|
||||||
import Paginated from '@arg/Paginated'
|
|
||||||
import { apiGet, apiPost } from '@/apis/HttpRequest'
|
|
||||||
import { Order } from '@enum/Order'
|
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'
|
import { RIGHTS } from '@/auth/RIGHTS'
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { Resolver, Query, Authorized, Arg, Mutation, Args } from 'type-graphql'
|
import { Resolver, Query, Authorized, Arg, Mutation, Args } from 'type-graphql'
|
||||||
|
|
||||||
|
import SubscribeNewsletterArgs from '@arg/SubscribeNewsletterArgs'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getKlickTippUser,
|
getKlickTippUser,
|
||||||
getKlicktippTagMap,
|
getKlicktippTagMap,
|
||||||
@ -6,7 +9,6 @@ import {
|
|||||||
klicktippSignIn,
|
klicktippSignIn,
|
||||||
} from '@/apis/KlicktippController'
|
} from '@/apis/KlicktippController'
|
||||||
import { RIGHTS } from '@/auth/RIGHTS'
|
import { RIGHTS } from '@/auth/RIGHTS'
|
||||||
import SubscribeNewsletterArgs from '@arg/SubscribeNewsletterArgs'
|
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
export class KlicktippResolver {
|
export class KlicktippResolver {
|
||||||
|
|||||||
@ -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 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'
|
import { calculateDecay } from '@/util/decay'
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|||||||
@ -4,8 +4,12 @@
|
|||||||
import { transactionLinkCode } from './TransactionLinkResolver'
|
import { transactionLinkCode } from './TransactionLinkResolver'
|
||||||
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
|
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
|
||||||
import { peterLustig } from '@/seeds/users/peter-lustig'
|
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 { userFactory } from '@/seeds/factory/user'
|
||||||
|
import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
|
||||||
|
import { transactionLinks } from '@/seeds/transactionLink/index'
|
||||||
import {
|
import {
|
||||||
login,
|
login,
|
||||||
createContributionLink,
|
createContributionLink,
|
||||||
@ -13,17 +17,22 @@ import {
|
|||||||
createContribution,
|
createContribution,
|
||||||
updateContribution,
|
updateContribution,
|
||||||
} from '@/seeds/graphql/mutations'
|
} from '@/seeds/graphql/mutations'
|
||||||
|
import { listTransactionLinksAdmin } from '@/seeds/graphql/queries'
|
||||||
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
||||||
|
import { User } from '@entity/User'
|
||||||
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
|
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
|
||||||
import Decimal from 'decimal.js-light'
|
import Decimal from 'decimal.js-light'
|
||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
|
|
||||||
let mutate: any, con: any
|
let mutate: any, query: any, con: any
|
||||||
let testEnv: any
|
let testEnv: any
|
||||||
|
|
||||||
|
let user: User
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
testEnv = await testEnvironment()
|
testEnv = await testEnvironment()
|
||||||
mutate = testEnv.mutate
|
mutate = testEnv.mutate
|
||||||
|
query = testEnv.query
|
||||||
con = testEnv.con
|
con = testEnv.con
|
||||||
await cleanDB()
|
await cleanDB()
|
||||||
await userFactory(testEnv, bibiBloxberg)
|
await userFactory(testEnv, bibiBloxberg)
|
||||||
@ -36,6 +45,7 @@ afterAll(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('TransactionLinkResolver', () => {
|
describe('TransactionLinkResolver', () => {
|
||||||
|
// TODO: have this test separated into a transactionLink and a contributionLink part (if possible)
|
||||||
describe('redeem daily Contribution Link', () => {
|
describe('redeem daily Contribution Link', () => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
let contributionLink: DbContributionLink | undefined
|
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', () => {
|
describe('transactionLinkCode', () => {
|
||||||
|
|||||||
@ -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 { 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 { User } from '@model/User'
|
||||||
import { calculateDecay } from '@/util/decay'
|
import { ContributionLink } from '@model/ContributionLink'
|
||||||
import { executeTransaction } from './TransactionResolver'
|
import { Decay } from '@model/Decay'
|
||||||
|
import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
|
||||||
import { Order } from '@enum/Order'
|
import { Order } from '@enum/Order'
|
||||||
import { ContributionType } from '@enum/ContributionType'
|
import { ContributionType } from '@enum/ContributionType'
|
||||||
import { ContributionStatus } from '@enum/ContributionStatus'
|
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 { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||||
import { ContributionCycleType } from '@enum/ContributionCycleType'
|
import { ContributionCycleType } from '@enum/ContributionCycleType'
|
||||||
|
import TransactionLinkArgs from '@arg/TransactionLinkArgs'
|
||||||
|
import Paginated from '@arg/Paginated'
|
||||||
|
import TransactionLinkFilters from '@arg/TransactionLinkFilters'
|
||||||
|
|
||||||
const QueryLinkResult = createUnionType({
|
import { backendLogger as logger } from '@/server/logger'
|
||||||
name: 'QueryLinkResult', // the name of the GraphQL union
|
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
|
||||||
types: () => [TransactionLink, ContributionLink] as const, // function that returns tuple of object types classes
|
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
|
// TODO: do not export, test it inside the resolver
|
||||||
export const transactionLinkCode = (date: Date): string => {
|
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")
|
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.userId = user.id
|
||||||
transactionLink.amount = amount
|
transactionLink.amount = amount
|
||||||
transactionLink.memo = memo
|
transactionLink.memo = memo
|
||||||
@ -87,7 +78,7 @@ export class TransactionLinkResolver {
|
|||||||
transactionLink.code = transactionLinkCode(createdDate)
|
transactionLink.code = transactionLinkCode(createdDate)
|
||||||
transactionLink.createdAt = createdDate
|
transactionLink.createdAt = createdDate
|
||||||
transactionLink.validUntil = validUntil
|
transactionLink.validUntil = validUntil
|
||||||
await dbTransactionLink.save(transactionLink).catch(() => {
|
await DbTransactionLink.save(transactionLink).catch(() => {
|
||||||
throw new Error('Unable to save transaction link')
|
throw new Error('Unable to save transaction link')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -102,7 +93,7 @@ export class TransactionLinkResolver {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const user = getUser(context)
|
const user = getUser(context)
|
||||||
|
|
||||||
const transactionLink = await dbTransactionLink.findOne({ id })
|
const transactionLink = await DbTransactionLink.findOne({ id })
|
||||||
if (!transactionLink) {
|
if (!transactionLink) {
|
||||||
throw new Error('Transaction Link not found!')
|
throw new Error('Transaction Link not found!')
|
||||||
}
|
}
|
||||||
@ -132,11 +123,11 @@ export class TransactionLinkResolver {
|
|||||||
)
|
)
|
||||||
return new ContributionLink(contributionLink)
|
return new ContributionLink(contributionLink)
|
||||||
} else {
|
} else {
|
||||||
const transactionLink = await dbTransactionLink.findOneOrFail({ code }, { withDeleted: true })
|
const transactionLink = await DbTransactionLink.findOneOrFail({ code }, { withDeleted: true })
|
||||||
const user = await dbUser.findOneOrFail({ id: transactionLink.userId })
|
const user = await DbUser.findOneOrFail({ id: transactionLink.userId })
|
||||||
let redeemedBy: User | null = null
|
let redeemedBy: User | null = null
|
||||||
if (transactionLink && transactionLink.redeemedBy) {
|
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)
|
return new TransactionLink(transactionLink, new User(user), redeemedBy)
|
||||||
}
|
}
|
||||||
@ -151,7 +142,7 @@ export class TransactionLinkResolver {
|
|||||||
): Promise<TransactionLink[]> {
|
): Promise<TransactionLink[]> {
|
||||||
const user = getUser(context)
|
const user = getUser(context)
|
||||||
// const now = new Date()
|
// const now = new Date()
|
||||||
const transactionLinks = await dbTransactionLink.find({
|
const transactionLinks = await DbTransactionLink.find({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
redeemedBy: null,
|
redeemedBy: null,
|
||||||
@ -321,8 +312,8 @@ export class TransactionLinkResolver {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
const transactionLink = await dbTransactionLink.findOneOrFail({ code })
|
const transactionLink = await DbTransactionLink.findOneOrFail({ code })
|
||||||
const linkedUser = await dbUser.findOneOrFail(
|
const linkedUser = await DbUser.findOneOrFail(
|
||||||
{ id: transactionLink.userId },
|
{ id: transactionLink.userId },
|
||||||
{ relations: ['emailContact'] },
|
{ relations: ['emailContact'] },
|
||||||
)
|
)
|
||||||
@ -350,4 +341,44 @@ export class TransactionLinkResolver {
|
|||||||
return true
|
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))),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,38 +1,30 @@
|
|||||||
/* eslint-disable new-cap */
|
/* eslint-disable new-cap */
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
import { backendLogger as logger } from '@/server/logger'
|
import Decimal from 'decimal.js-light'
|
||||||
|
|
||||||
import { Context, getUser } from '@/server/context'
|
|
||||||
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
|
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
|
||||||
import { getCustomRepository, getConnection, In } from '@dbTools/typeorm'
|
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 { User as dbUser } from '@entity/User'
|
||||||
import { Transaction as dbTransaction } from '@entity/Transaction'
|
import { Transaction as dbTransaction } from '@entity/Transaction'
|
||||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
||||||
|
import { TransactionRepository } from '@repository/Transaction'
|
||||||
|
import { TransactionLinkRepository } from '@repository/TransactionLink'
|
||||||
|
|
||||||
|
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 { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||||
|
import TransactionSendArgs from '@arg/TransactionSendArgs'
|
||||||
|
import Paginated from '@arg/Paginated'
|
||||||
|
|
||||||
|
import { backendLogger as logger } from '@/server/logger'
|
||||||
|
import { Context, getUser } from '@/server/context'
|
||||||
import { calculateBalance, isHexPublicKey } from '@/util/validate'
|
import { calculateBalance, isHexPublicKey } from '@/util/validate'
|
||||||
import { RIGHTS } from '@/auth/RIGHTS'
|
import { RIGHTS } from '@/auth/RIGHTS'
|
||||||
import { User } from '@model/User'
|
|
||||||
import { communityUser } from '@/util/communityUser'
|
import { communityUser } from '@/util/communityUser'
|
||||||
import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualTransactions'
|
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 {
|
import {
|
||||||
sendTransactionLinkRedeemedEmail,
|
sendTransactionLinkRedeemedEmail,
|
||||||
sendTransactionReceivedEmail,
|
sendTransactionReceivedEmail,
|
||||||
@ -40,6 +32,10 @@ import {
|
|||||||
import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event'
|
import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event'
|
||||||
import { eventProtocol } from '@/event/EventProtocolEmitter'
|
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 (
|
export const executeTransaction = async (
|
||||||
amount: Decimal,
|
amount: Decimal,
|
||||||
memo: string,
|
memo: string,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||||
|
|
||||||
|
import { objectValuesToArray } from '@/util/utilities'
|
||||||
import { testEnvironment, headerPushMock, resetToken, cleanDB } from '@test/helpers'
|
import { testEnvironment, headerPushMock, resetToken, cleanDB } from '@test/helpers'
|
||||||
import { logger, i18n as localization } from '@test/testSetup'
|
import { logger, i18n as localization } from '@test/testSetup'
|
||||||
import { printTimeDuration } from '@/util/time'
|
import { printTimeDuration } from '@/util/time'
|
||||||
@ -15,8 +16,11 @@ import {
|
|||||||
updateUserInfos,
|
updateUserInfos,
|
||||||
createContribution,
|
createContribution,
|
||||||
confirmContribution,
|
confirmContribution,
|
||||||
|
setUserRole,
|
||||||
|
deleteUser,
|
||||||
|
unDeleteUser,
|
||||||
} from '@/seeds/graphql/mutations'
|
} 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 { GraphQLError } from 'graphql'
|
||||||
import { User } from '@entity/User'
|
import { User } from '@entity/User'
|
||||||
import CONFIG from '@/config'
|
import CONFIG from '@/config'
|
||||||
@ -25,7 +29,6 @@ import {
|
|||||||
sendAccountMultiRegistrationEmail,
|
sendAccountMultiRegistrationEmail,
|
||||||
sendResetPasswordEmail,
|
sendResetPasswordEmail,
|
||||||
} from '@/emails/sendEmailVariants'
|
} from '@/emails/sendEmailVariants'
|
||||||
import { activationLink } from './UserResolver'
|
|
||||||
import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
|
import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
|
||||||
import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
|
import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
|
||||||
import { ContributionLink } from '@model/ContributionLink'
|
import { ContributionLink } from '@model/ContributionLink'
|
||||||
@ -38,6 +41,8 @@ import { UserContact } from '@entity/UserContact'
|
|||||||
import { OptInType } from '../enum/OptInType'
|
import { OptInType } from '../enum/OptInType'
|
||||||
import { UserContactType } from '../enum/UserContactType'
|
import { UserContactType } from '../enum/UserContactType'
|
||||||
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
|
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 { encryptPassword } from '@/password/PasswordEncryptor'
|
||||||
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
|
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
|
||||||
import { SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
|
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 mutate: any, query: any, con: any
|
||||||
let testEnv: any
|
let testEnv: any
|
||||||
|
|
||||||
@ -807,12 +814,8 @@ describe('UserResolver', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('user exists in DB', () => {
|
describe('user exists in DB', () => {
|
||||||
let emailContact: UserContact
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await userFactory(testEnv, bibiBloxberg)
|
await userFactory(testEnv, bibiBloxberg)
|
||||||
// await resetEntity(LoginEmailOptIn)
|
|
||||||
emailContact = await UserContact.findOneOrFail(variables)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -821,7 +824,7 @@ describe('UserResolver', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('duration not expired', () => {
|
describe('duration not expired', () => {
|
||||||
it('returns true', async () => {
|
it('throws an error', async () => {
|
||||||
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
|
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
errors: [
|
errors: [
|
||||||
@ -847,7 +850,6 @@ describe('UserResolver', () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
it('sends reset password email', () => {
|
it('sends reset password email', () => {
|
||||||
expect(sendResetPasswordEmail).toBeCalledWith({
|
expect(sendResetPasswordEmail).toBeCalledWith({
|
||||||
@ -855,13 +857,14 @@ describe('UserResolver', () => {
|
|||||||
lastName: 'Bloxberg',
|
lastName: 'Bloxberg',
|
||||||
email: 'bibi@bloxberg.de',
|
email: 'bibi@bloxberg.de',
|
||||||
language: 'de',
|
language: 'de',
|
||||||
resetLink: activationLink(emailContact.emailVerificationCode),
|
resetLink: expect.any(String),
|
||||||
timeDurationObject: expect.objectContaining({
|
timeDurationObject: expect.objectContaining({
|
||||||
hours: expect.any(Number),
|
hours: expect.any(Number),
|
||||||
minutes: expect.any(Number),
|
minutes: expect.any(Number),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('request reset password again', () => {
|
describe('request reset password again', () => {
|
||||||
it('thows an error', async () => {
|
it('thows an error', async () => {
|
||||||
@ -1253,6 +1256,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', () => {
|
describe('printTimeDuration', () => {
|
||||||
|
|||||||
@ -1,28 +1,51 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { backendLogger as logger } from '@/server/logger'
|
|
||||||
import i18n from 'i18n'
|
import i18n from 'i18n'
|
||||||
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql'
|
import {
|
||||||
|
Resolver,
|
||||||
|
Query,
|
||||||
|
Args,
|
||||||
|
Arg,
|
||||||
|
Authorized,
|
||||||
|
Ctx,
|
||||||
|
UseMiddleware,
|
||||||
|
Mutation,
|
||||||
|
Int,
|
||||||
|
} from 'type-graphql'
|
||||||
import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm'
|
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 { User as DbUser } from '@entity/User'
|
||||||
import { UserContact as DbUserContact } from '@entity/UserContact'
|
import { UserContact as DbUserContact } from '@entity/UserContact'
|
||||||
import { communityDbUser } from '@/util/communityUser'
|
import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink'
|
||||||
import { getTimeDurationObject, printTimeDuration } from '@/util/time'
|
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
||||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
import { UserRepository } from '@repository/User'
|
||||||
import { ContributionLink as dbContributionLink } from '@entity/ContributionLink'
|
|
||||||
import { encode } from '@/auth/JWT'
|
import { User } from '@model/User'
|
||||||
import CreateUserArgs from '@arg/CreateUserArgs'
|
import { SearchAdminUsersResult } from '@model/AdminUser'
|
||||||
import UnsecureLoginArgs from '@arg/UnsecureLoginArgs'
|
import { UserAdmin, SearchUsersResult } from '@model/UserAdmin'
|
||||||
import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
|
|
||||||
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
|
|
||||||
import { OptInType } from '@enum/OptInType'
|
import { OptInType } from '@enum/OptInType'
|
||||||
|
import { Order } from '@enum/Order'
|
||||||
|
import { UserContactType } from '@enum/UserContactType'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
sendAccountActivationEmail,
|
sendAccountActivationEmail,
|
||||||
sendAccountMultiRegistrationEmail,
|
sendAccountMultiRegistrationEmail,
|
||||||
sendResetPasswordEmail,
|
sendResetPasswordEmail,
|
||||||
} from '@/emails/sendEmailVariants'
|
} 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 { klicktippSignIn } from '@/apis/KlicktippController'
|
||||||
import { RIGHTS } from '@/auth/RIGHTS'
|
import { RIGHTS } from '@/auth/RIGHTS'
|
||||||
import { hasElopageBuys } from '@/util/hasElopageBuys'
|
import { hasElopageBuys } from '@/util/hasElopageBuys'
|
||||||
@ -36,13 +59,8 @@ import {
|
|||||||
EventSendConfirmationEmail,
|
EventSendConfirmationEmail,
|
||||||
EventActivateAccount,
|
EventActivateAccount,
|
||||||
} from '@/event/Event'
|
} from '@/event/Event'
|
||||||
import { getUserCreation } from './util/creations'
|
import { getUserCreation, getUserCreations } from './util/creations'
|
||||||
import { UserContactType } from '../enum/UserContactType'
|
import { FULL_CREATION_AVAILABLE } from './const/const'
|
||||||
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 { isValidPassword, SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
|
||||||
import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor'
|
import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor'
|
||||||
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
|
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
|
||||||
@ -108,16 +126,6 @@ const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => {
|
|||||||
return [pubKey, privKey]
|
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 => {
|
const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => {
|
||||||
logger.trace('SecretKeyCryptographyEncrypt...')
|
logger.trace('SecretKeyCryptographyEncrypt...')
|
||||||
const encrypted = Buffer.alloc(message.length + sodium.crypto_secretbox_MACBYTES)
|
const encrypted = Buffer.alloc(message.length + sodium.crypto_secretbox_MACBYTES)
|
||||||
@ -153,91 +161,6 @@ const newEmailContact = (email: string, userId: number): DbUserContact => {
|
|||||||
logger.debug(`newEmailContact...successful: ${emailContact}`)
|
logger.debug(`newEmailContact...successful: ${emailContact}`)
|
||||||
return 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 => {
|
export const activationLink = (verificationCode: BigInt): string => {
|
||||||
logger.debug(`activationLink(${verificationCode})...`)
|
logger.debug(`activationLink(${verificationCode})...`)
|
||||||
@ -348,6 +271,7 @@ export class UserResolver {
|
|||||||
@Authorized([RIGHTS.LOGOUT])
|
@Authorized([RIGHTS.LOGOUT])
|
||||||
@Mutation(() => String)
|
@Mutation(() => String)
|
||||||
async logout(): Promise<boolean> {
|
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.
|
// 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)
|
// 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.
|
// The functionality is fully client side - the client just needs to delete his token with the current implementation.
|
||||||
@ -450,7 +374,7 @@ export class UserResolver {
|
|||||||
logger.debug('new dbUser=' + dbUser)
|
logger.debug('new dbUser=' + dbUser)
|
||||||
if (redeemCode) {
|
if (redeemCode) {
|
||||||
if (redeemCode.match(/^CL-/)) {
|
if (redeemCode.match(/^CL-/)) {
|
||||||
const contributionLink = await dbContributionLink.findOne({
|
const contributionLink = await DbContributionLink.findOne({
|
||||||
code: redeemCode.replace('CL-', ''),
|
code: redeemCode.replace('CL-', ''),
|
||||||
})
|
})
|
||||||
logger.info('redeemCode found contributionLink=' + contributionLink)
|
logger.info('redeemCode found contributionLink=' + contributionLink)
|
||||||
@ -459,7 +383,7 @@ export class UserResolver {
|
|||||||
eventRedeemRegister.contributionId = contributionLink.id
|
eventRedeemRegister.contributionId = contributionLink.id
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const transactionLink = await dbTransactionLink.findOne({ code: redeemCode })
|
const transactionLink = await DbTransactionLink.findOne({ code: redeemCode })
|
||||||
logger.info('redeemCode found transactionLink=' + transactionLink)
|
logger.info('redeemCode found transactionLink=' + transactionLink)
|
||||||
if (transactionLink) {
|
if (transactionLink) {
|
||||||
dbUser.referrerId = transactionLink.userId
|
dbUser.referrerId = transactionLink.userId
|
||||||
@ -561,32 +485,45 @@ export class UserResolver {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// can be both types: REGISTER and RESET_PASSWORD
|
if (!canEmailResend(user.emailContact.updatedAt || user.emailContact.createdAt)) {
|
||||||
// let optInCode = await LoginEmailOptIn.findOne({
|
logger.error(
|
||||||
// userId: user.id,
|
`email already sent less than ${printTimeDuration(
|
||||||
// })
|
CONFIG.EMAIL_CODE_REQUEST_TIME,
|
||||||
// let optInCode = user.emailContact.emailVerificationCode
|
)} minutes ago`,
|
||||||
const dbUserContact = await checkEmailVerificationCode(
|
|
||||||
user.emailContact,
|
|
||||||
OptInType.EMAIL_OPT_IN_RESET_PASSWORD,
|
|
||||||
)
|
)
|
||||||
|
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)
|
user.emailContact.updatedAt = new Date()
|
||||||
logger.info(`optInCode for ${email}=${dbUserContact}`)
|
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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const emailSent = await sendResetPasswordEmail({
|
const emailSent = await sendResetPasswordEmail({
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
email,
|
email,
|
||||||
language: user.language,
|
language: user.language,
|
||||||
resetLink: activationLink(dbUserContact.emailVerificationCode),
|
resetLink: activationLink(user.emailContact.emailVerificationCode),
|
||||||
timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME),
|
timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME),
|
||||||
})
|
})
|
||||||
|
|
||||||
/* uncomment this, when you need the activation link on the console */
|
/* uncomment this, when you need the activation link on the console */
|
||||||
// In case EMails are disabled log the activation link for the user
|
// In case EMails are disabled log the activation link for the user
|
||||||
if (!emailSent) {
|
if (!emailSent) {
|
||||||
logger.debug(`Reset password link: ${activationLink(dbUserContact.emailVerificationCode)}`)
|
logger.debug(
|
||||||
|
`Reset password link: ${activationLink(user.emailContact.emailVerificationCode)}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
logger.info(`forgotPassword(${email}) successful...`)
|
logger.info(`forgotPassword(${email}) successful...`)
|
||||||
|
|
||||||
@ -624,7 +561,7 @@ export class UserResolver {
|
|||||||
})
|
})
|
||||||
logger.debug('userContact loaded...')
|
logger.debug('userContact loaded...')
|
||||||
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
|
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
|
||||||
if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
|
if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
|
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
|
||||||
)
|
)
|
||||||
@ -728,7 +665,7 @@ export class UserResolver {
|
|||||||
const userContact = await DbUserContact.findOneOrFail({ emailVerificationCode: optIn })
|
const userContact = await DbUserContact.findOneOrFail({ emailVerificationCode: optIn })
|
||||||
logger.debug(`found optInCode=${userContact}`)
|
logger.debug(`found optInCode=${userContact}`)
|
||||||
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
|
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
|
||||||
if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
|
if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
|
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
|
||||||
)
|
)
|
||||||
@ -864,6 +801,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> {
|
export async function findUserByEmail(email: string): Promise<DbUser> {
|
||||||
@ -904,10 +1041,7 @@ const isOptInValid = (optIn: LoginEmailOptIn): boolean => {
|
|||||||
return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME)
|
return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME)
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
const isEmailVerificationCodeValid = (updatedAt: Date | null): boolean => {
|
const isEmailVerificationCodeValid = (updatedAt: Date): boolean => {
|
||||||
if (updatedAt == null) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_VALID_TIME)
|
return isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_VALID_TIME)
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
|
|||||||
7
backend/src/graphql/union/QueryLinkResult.ts
Normal file
7
backend/src/graphql/union/QueryLinkResult.ts
Normal 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
|
||||||
|
})
|
||||||
7
backend/test/helpers.test.ts
Normal file
7
backend/test/helpers.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -5,6 +5,7 @@ import { createTestClient } from 'apollo-server-testing'
|
|||||||
import createServer from '../src/server/createServer'
|
import createServer from '../src/server/createServer'
|
||||||
import { initialize } from '@dbTools/helpers'
|
import { initialize } from '@dbTools/helpers'
|
||||||
import { entities } from '@entity/index'
|
import { entities } from '@entity/index'
|
||||||
|
import { i18n, logger } from './testSetup'
|
||||||
|
|
||||||
export const headerPushMock = jest.fn((t) => {
|
export const headerPushMock = jest.fn((t) => {
|
||||||
context.token = t.value
|
context.token = t.value
|
||||||
@ -26,8 +27,8 @@ export const cleanDB = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const testEnvironment = async (logger?: any, localization?: any) => {
|
export const testEnvironment = async (testLogger: any = logger, testI18n: any = i18n) => {
|
||||||
const server = await createServer(context, logger, localization)
|
const server = await createServer(context, testLogger, testI18n)
|
||||||
const con = server.con
|
const con = server.con
|
||||||
const testClient = createTestClient(server.apollo)
|
const testClient = createTestClient(server.apollo)
|
||||||
const mutate = testClient.mutate
|
const mutate = testClient.mutate
|
||||||
|
|||||||
@ -51,6 +51,7 @@
|
|||||||
"@arg/*": ["src/graphql/arg/*"],
|
"@arg/*": ["src/graphql/arg/*"],
|
||||||
"@enum/*": ["src/graphql/enum/*"],
|
"@enum/*": ["src/graphql/enum/*"],
|
||||||
"@model/*": ["src/graphql/model/*"],
|
"@model/*": ["src/graphql/model/*"],
|
||||||
|
"@union/*": ["src/graphql/union/*"],
|
||||||
"@repository/*": ["src/typeorm/repository/*"],
|
"@repository/*": ["src/typeorm/repository/*"],
|
||||||
"@test/*": ["test/*"],
|
"@test/*": ["test/*"],
|
||||||
/* external */
|
/* external */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user