mirror of
https://github.com/IT4Change/gradido.git
synced 2025-12-13 07:45:54 +00:00
Merge branch 'master' into 2290-New-Design
This commit is contained in:
commit
8b965ce7d2
22
CHANGELOG.md
22
CHANGELOG.md
@ -4,8 +4,30 @@ All notable changes to this project will be documented in this file. Dates are d
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [1.16.0](https://github.com/gradido/gradido/compare/1.15.0...1.16.0)
|
||||
|
||||
- refactor(backend): cleaning user related old password junk [`#2426`](https://github.com/gradido/gradido/pull/2426)
|
||||
- fix(database): consistent transaction table [`#2453`](https://github.com/gradido/gradido/pull/2453)
|
||||
- refactor(backend): dissolve admin resolver [`#2416`](https://github.com/gradido/gradido/pull/2416)
|
||||
- fix(backend): email verification code never expired [`#2418`](https://github.com/gradido/gradido/pull/2418)
|
||||
- fix(database): consistent deleted at bewteen users and user contacts [`#2451`](https://github.com/gradido/gradido/pull/2451)
|
||||
- feat(backend): log client timezone offset [`#2454`](https://github.com/gradido/gradido/pull/2454)
|
||||
- refactor(backend): refactor more emails to translatables [`#2398`](https://github.com/gradido/gradido/pull/2398)
|
||||
- fix(backend): delete / undelete email contact as well [`#2444`](https://github.com/gradido/gradido/pull/2444)
|
||||
- feat(backend): 🍰 Mark creation via link [`#2363`](https://github.com/gradido/gradido/pull/2363)
|
||||
- fix(backend): run all timers for high values [`#2452`](https://github.com/gradido/gradido/pull/2452)
|
||||
- fix(backend): critical bug [`#2443`](https://github.com/gradido/gradido/pull/2443)
|
||||
- fix(other): missing files for docker production build [`#2442`](https://github.com/gradido/gradido/pull/2442)
|
||||
- fix(frontend): in contribution messages formular a message can be send twice, when clicking the submit button fast [`#2424`](https://github.com/gradido/gradido/pull/2424)
|
||||
- fix(backend): wrong month for contribution near turn of month [`#2201`](https://github.com/gradido/gradido/pull/2201)
|
||||
- feat(backend): add federation config properties [`#2374`](https://github.com/gradido/gradido/pull/2374)
|
||||
- fix(backend): moved all jest & type-definition related packages into the `devDependencies` section [`#2385`](https://github.com/gradido/gradido/pull/2385)
|
||||
|
||||
#### [1.15.0](https://github.com/gradido/gradido/compare/1.14.1...1.15.0)
|
||||
|
||||
> 26 November 2022
|
||||
|
||||
- chore(release): v1.15.0 [`#2425`](https://github.com/gradido/gradido/pull/2425)
|
||||
- fix(database): wrong balance and decay values [`#2423`](https://github.com/gradido/gradido/pull/2423)
|
||||
- fix(backend): wrong balance after transaction receive [`#2422`](https://github.com/gradido/gradido/pull/2422)
|
||||
- feat(other): feature gradido roadmap [`#2301`](https://github.com/gradido/gradido/pull/2301)
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"description": "Administraion Interface for Gradido",
|
||||
"main": "index.js",
|
||||
"author": "Moriz Wahl",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
|
||||
@ -107,9 +107,7 @@ COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json
|
||||
COPY --from=build ${DOCKER_WORKDIR}/tsconfig.json ./tsconfig.json
|
||||
# Copy log4js-config.json to provide log configuration
|
||||
COPY --from=build ${DOCKER_WORKDIR}/log4js-config.json ./log4js-config.json
|
||||
# Copy memonic type since its referenced in the sources
|
||||
# TODO: remove
|
||||
COPY --from=build ${DOCKER_WORKDIR}/src/config/mnemonic.uncompressed_buffer13116.txt ./src/config/mnemonic.uncompressed_buffer13116.txt
|
||||
|
||||
# Copy run scripts run/
|
||||
# COPY --from=build ${DOCKER_WORKDIR}/run ./run
|
||||
|
||||
|
||||
@ -9,9 +9,10 @@ module.exports = {
|
||||
modulePathIgnorePatterns: ['<rootDir>/build/'],
|
||||
moduleNameMapper: {
|
||||
'@/(.*)': '<rootDir>/src/$1',
|
||||
'@model/(.*)': '<rootDir>/src/graphql/model/$1',
|
||||
'@arg/(.*)': '<rootDir>/src/graphql/arg/$1',
|
||||
'@enum/(.*)': '<rootDir>/src/graphql/enum/$1',
|
||||
'@model/(.*)': '<rootDir>/src/graphql/model/$1',
|
||||
'@union/(.*)': '<rootDir>/src/graphql/union/$1',
|
||||
'@repository/(.*)': '<rootDir>/src/typeorm/repository/$1',
|
||||
'@test/(.*)': '<rootDir>/test/$1',
|
||||
'@entity/(.*)':
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gradido-backend",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
|
||||
"main": "src/index.ts",
|
||||
"repository": "https://github.com/gradido/gradido/backend",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { JwtPayload } from 'jsonwebtoken'
|
||||
|
||||
export interface CustomJwtPayload extends JwtPayload {
|
||||
pubKey: Buffer
|
||||
gradidoID: string
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ export const decode = (token: string): CustomJwtPayload | null => {
|
||||
}
|
||||
}
|
||||
|
||||
export const encode = (pubKey: Buffer): string => {
|
||||
const token = jwt.sign({ pubKey }, CONFIG.JWT_SECRET, {
|
||||
export const encode = (gradidoID: string): string => {
|
||||
const token = jwt.sign({ gradidoID }, CONFIG.JWT_SECRET, {
|
||||
expiresIn: CONFIG.JWT_EXPIRES_IN,
|
||||
})
|
||||
return token
|
||||
|
||||
@ -10,7 +10,7 @@ Decimal.set({
|
||||
})
|
||||
|
||||
const constants = {
|
||||
DB_VERSION: '0055-consistent_deleted_users',
|
||||
DB_VERSION: '0057-clear_old_password_junk',
|
||||
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
|
||||
LOG4JS_CONFIG: 'log4js-config.json',
|
||||
// default log level on production should be info
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -5,9 +5,8 @@ import { AuthChecker } from 'type-graphql'
|
||||
import { decode, encode } from '@/auth/JWT'
|
||||
import { ROLE_UNAUTHORIZED, ROLE_USER, ROLE_ADMIN } from '@/auth/ROLES'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { getCustomRepository } from '@dbTools/typeorm'
|
||||
import { UserRepository } from '@repository/User'
|
||||
import { INALIENABLE_RIGHTS } from '@/auth/INALIENABLE_RIGHTS'
|
||||
import { User } from '@entity/User'
|
||||
|
||||
const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
|
||||
context.role = ROLE_UNAUTHORIZED // unauthorized user
|
||||
@ -26,14 +25,16 @@ const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
|
||||
if (!decoded) {
|
||||
throw new Error('403.13 - Client certificate revoked')
|
||||
}
|
||||
// Set context pubKey
|
||||
context.pubKey = Buffer.from(decoded.pubKey).toString('hex')
|
||||
// Set context gradidoID
|
||||
context.gradidoID = decoded.gradidoID
|
||||
|
||||
// TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests
|
||||
// TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey
|
||||
const userRepository = getCustomRepository(UserRepository)
|
||||
try {
|
||||
const user = await userRepository.findByPubkeyHex(context.pubKey)
|
||||
const user = await User.findOneOrFail({
|
||||
where: { gradidoID: decoded.gradidoID },
|
||||
relations: ['emailContact'],
|
||||
})
|
||||
context.user = user
|
||||
context.role = user.isAdmin ? ROLE_ADMIN : ROLE_USER
|
||||
} catch {
|
||||
@ -48,7 +49,7 @@ const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
|
||||
}
|
||||
|
||||
// set new header token
|
||||
context.setHeaders.push({ key: 'token', value: encode(decoded.pubKey) })
|
||||
context.setHeaders.push({ key: 'token', value: encode(decoded.gradidoID) })
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 { Context, getUser } from '@/server/context'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { Resolver, Query, Ctx, Authorized } from 'type-graphql'
|
||||
import { getCustomRepository } from '@dbTools/typeorm'
|
||||
|
||||
import { Transaction as dbTransaction } from '@entity/Transaction'
|
||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
||||
|
||||
import { TransactionLinkRepository } from '@repository/TransactionLink'
|
||||
|
||||
import { Balance } from '@model/Balance'
|
||||
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
import { Context, getUser } from '@/server/context'
|
||||
import { calculateDecay } from '@/util/decay'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { Transaction as dbTransaction } from '@entity/Transaction'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { GdtResolver } from './GdtResolver'
|
||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
||||
import { getCustomRepository } from '@dbTools/typeorm'
|
||||
import { TransactionLinkRepository } from '@repository/TransactionLink'
|
||||
|
||||
@Resolver()
|
||||
export class BalanceResolver {
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { Resolver, Query, Authorized } from 'type-graphql'
|
||||
|
||||
import { Community } from '@model/Community'
|
||||
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import CONFIG from '@/config'
|
||||
import { Community } from '@model/Community'
|
||||
|
||||
@Resolver()
|
||||
export class CommunityResolver {
|
||||
|
||||
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 { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { Context, getUser } from '@/server/context'
|
||||
import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage'
|
||||
import { Arg, Args, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql'
|
||||
import ContributionMessageArgs from '@arg/ContributionMessageArgs'
|
||||
import { Contribution } from '@entity/Contribution'
|
||||
import { ContributionMessageType } from '@enum/MessageType'
|
||||
import { ContributionStatus } from '@enum/ContributionStatus'
|
||||
import { getConnection } from '@dbTools/typeorm'
|
||||
import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage'
|
||||
import Paginated from '@arg/Paginated'
|
||||
import { Order } from '@enum/Order'
|
||||
import { sendAddedContributionMessageEmail } from '@/emails/sendEmailVariants'
|
||||
|
||||
@Resolver()
|
||||
export class ContributionMessageResolver {
|
||||
@ -26,7 +31,7 @@ export class ContributionMessageResolver {
|
||||
await queryRunner.startTransaction('REPEATABLE READ')
|
||||
const contributionMessage = DbContributionMessage.create()
|
||||
try {
|
||||
const contribution = await Contribution.findOne({ id: contributionId })
|
||||
const contribution = await DbContribution.findOne({ id: contributionId })
|
||||
if (!contribution) {
|
||||
throw new Error('Contribution not found')
|
||||
}
|
||||
@ -44,7 +49,7 @@ export class ContributionMessageResolver {
|
||||
|
||||
if (contribution.contributionStatus === ContributionStatus.IN_PROGRESS) {
|
||||
contribution.contributionStatus = ContributionStatus.PENDING
|
||||
await queryRunner.manager.update(Contribution, { id: contributionId }, contribution)
|
||||
await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution)
|
||||
}
|
||||
await queryRunner.commitTransaction()
|
||||
} catch (e) {
|
||||
@ -82,4 +87,73 @@ export class ContributionMessageResolver {
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE])
|
||||
@Mutation(() => ContributionMessage)
|
||||
async adminCreateContributionMessage(
|
||||
@Args() { contributionId, message }: ContributionMessageArgs,
|
||||
@Ctx() context: Context,
|
||||
): Promise<ContributionMessage> {
|
||||
const user = getUser(context)
|
||||
if (!user.emailContact) {
|
||||
user.emailContact = await UserContact.findOneOrFail({ where: { id: user.emailId } })
|
||||
}
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
await queryRunner.connect()
|
||||
await queryRunner.startTransaction('REPEATABLE READ')
|
||||
const contributionMessage = DbContributionMessage.create()
|
||||
try {
|
||||
const contribution = await DbContribution.findOne({
|
||||
where: { id: contributionId },
|
||||
relations: ['user'],
|
||||
})
|
||||
if (!contribution) {
|
||||
logger.error('Contribution not found')
|
||||
throw new Error('Contribution not found')
|
||||
}
|
||||
if (contribution.userId === user.id) {
|
||||
logger.error('Admin can not answer on own contribution')
|
||||
throw new Error('Admin can not answer on own contribution')
|
||||
}
|
||||
if (!contribution.user.emailContact) {
|
||||
contribution.user.emailContact = await UserContact.findOneOrFail({
|
||||
where: { id: contribution.user.emailId },
|
||||
})
|
||||
}
|
||||
contributionMessage.contributionId = contributionId
|
||||
contributionMessage.createdAt = new Date()
|
||||
contributionMessage.message = message
|
||||
contributionMessage.userId = user.id
|
||||
contributionMessage.type = ContributionMessageType.DIALOG
|
||||
contributionMessage.isModerator = true
|
||||
await queryRunner.manager.insert(DbContributionMessage, contributionMessage)
|
||||
|
||||
if (
|
||||
contribution.contributionStatus === ContributionStatus.DELETED ||
|
||||
contribution.contributionStatus === ContributionStatus.DENIED ||
|
||||
contribution.contributionStatus === ContributionStatus.PENDING
|
||||
) {
|
||||
contribution.contributionStatus = ContributionStatus.IN_PROGRESS
|
||||
await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution)
|
||||
}
|
||||
|
||||
await sendAddedContributionMessageEmail({
|
||||
firstName: contribution.user.firstName,
|
||||
lastName: contribution.user.lastName,
|
||||
email: contribution.user.emailContact.email,
|
||||
language: contribution.user.language,
|
||||
senderFirstName: user.firstName,
|
||||
senderLastName: user.lastName,
|
||||
contributionMemo: contribution.memo,
|
||||
})
|
||||
await queryRunner.commitTransaction()
|
||||
} catch (e) {
|
||||
await queryRunner.rollbackTransaction()
|
||||
logger.error(`ContributionMessage was not successful: ${e}`)
|
||||
throw new Error(`ContributionMessage was not successful: ${e}`)
|
||||
} finally {
|
||||
await queryRunner.release()
|
||||
}
|
||||
return new ContributionMessage(contributionMessage, user)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,27 +1,55 @@
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
import { Contribution as dbContribution } from '@entity/Contribution'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
|
||||
import { FindOperator, IsNull, getConnection } from '@dbTools/typeorm'
|
||||
import ContributionArgs from '@arg/ContributionArgs'
|
||||
import Paginated from '@arg/Paginated'
|
||||
import { FindOperator, IsNull, In, getConnection } from '@dbTools/typeorm'
|
||||
|
||||
import { Contribution as DbContribution } from '@entity/Contribution'
|
||||
import { ContributionMessage } from '@entity/ContributionMessage'
|
||||
import { UserContact } from '@entity/UserContact'
|
||||
import { User as DbUser } from '@entity/User'
|
||||
import { Transaction as DbTransaction } from '@entity/Transaction'
|
||||
|
||||
import { AdminCreateContributions } from '@model/AdminCreateContributions'
|
||||
import { AdminUpdateContribution } from '@model/AdminUpdateContribution'
|
||||
import { Contribution, ContributionListResult } from '@model/Contribution'
|
||||
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
|
||||
import { Decay } from '@model/Decay'
|
||||
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||
import { Order } from '@enum/Order'
|
||||
import { ContributionType } from '@enum/ContributionType'
|
||||
import { ContributionStatus } from '@enum/ContributionStatus'
|
||||
import { Contribution, ContributionListResult } from '@model/Contribution'
|
||||
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
|
||||
import { validateContribution, getUserCreation, updateCreations } from './util/creations'
|
||||
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
|
||||
import { ContributionMessage } from '@entity/ContributionMessage'
|
||||
import { ContributionMessageType } from '@enum/MessageType'
|
||||
import ContributionArgs from '@arg/ContributionArgs'
|
||||
import Paginated from '@arg/Paginated'
|
||||
import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs'
|
||||
import AdminUpdateContributionArgs from '@arg/AdminUpdateContributionArgs'
|
||||
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
import {
|
||||
getUserCreation,
|
||||
getUserCreations,
|
||||
validateContribution,
|
||||
updateCreations,
|
||||
isValidDateString,
|
||||
} from './util/creations'
|
||||
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS, FULL_CREATION_AVAILABLE } from './const/const'
|
||||
import {
|
||||
Event,
|
||||
EventContributionCreate,
|
||||
EventContributionDelete,
|
||||
EventContributionUpdate,
|
||||
EventContributionConfirm,
|
||||
EventAdminContributionCreate,
|
||||
EventAdminContributionDelete,
|
||||
EventAdminContributionUpdate,
|
||||
} from '@/event/Event'
|
||||
import { eventProtocol } from '@/event/EventProtocolEmitter'
|
||||
import { calculateDecay } from '@/util/decay'
|
||||
import {
|
||||
sendContributionConfirmedEmail,
|
||||
sendContributionRejectedEmail,
|
||||
} from '@/emails/sendEmailVariants'
|
||||
|
||||
@Resolver()
|
||||
export class ContributionResolver {
|
||||
@ -50,7 +78,7 @@ export class ContributionResolver {
|
||||
const creationDateObj = new Date(creationDate)
|
||||
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
|
||||
|
||||
const contribution = dbContribution.create()
|
||||
const contribution = DbContribution.create()
|
||||
contribution.userId = user.id
|
||||
contribution.amount = amount
|
||||
contribution.createdAt = new Date()
|
||||
@ -60,7 +88,7 @@ export class ContributionResolver {
|
||||
contribution.contributionStatus = ContributionStatus.PENDING
|
||||
|
||||
logger.trace('contribution to save', contribution)
|
||||
await dbContribution.save(contribution)
|
||||
await DbContribution.save(contribution)
|
||||
|
||||
const eventCreateContribution = new EventContributionCreate()
|
||||
eventCreateContribution.userId = user.id
|
||||
@ -79,7 +107,7 @@ export class ContributionResolver {
|
||||
): Promise<boolean> {
|
||||
const event = new Event()
|
||||
const user = getUser(context)
|
||||
const contribution = await dbContribution.findOne(id)
|
||||
const contribution = await DbContribution.findOne(id)
|
||||
if (!contribution) {
|
||||
logger.error('Contribution not found for given id')
|
||||
throw new Error('Contribution not found for given id.')
|
||||
@ -128,7 +156,7 @@ export class ContributionResolver {
|
||||
const [contributions, count] = await getConnection()
|
||||
.createQueryBuilder()
|
||||
.select('c')
|
||||
.from(dbContribution, 'c')
|
||||
.from(DbContribution, 'c')
|
||||
.leftJoinAndSelect('c.messages', 'm')
|
||||
.where(where)
|
||||
.withDeleted()
|
||||
@ -152,7 +180,7 @@ export class ContributionResolver {
|
||||
const [dbContributions, count] = await getConnection()
|
||||
.createQueryBuilder()
|
||||
.select('c')
|
||||
.from(dbContribution, 'c')
|
||||
.from(DbContribution, 'c')
|
||||
.innerJoinAndSelect('c.user', 'u')
|
||||
.orderBy('c.createdAt', order)
|
||||
.limit(pageSize)
|
||||
@ -185,7 +213,7 @@ export class ContributionResolver {
|
||||
|
||||
const user = getUser(context)
|
||||
|
||||
const contributionToUpdate = await dbContribution.findOne({
|
||||
const contributionToUpdate = await DbContribution.findOne({
|
||||
where: { id: contributionId, confirmedAt: IsNull() },
|
||||
})
|
||||
if (!contributionToUpdate) {
|
||||
@ -240,7 +268,7 @@ export class ContributionResolver {
|
||||
contributionToUpdate.contributionDate = new Date(creationDate)
|
||||
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
|
||||
contributionToUpdate.updatedAt = new Date()
|
||||
dbContribution.save(contributionToUpdate)
|
||||
DbContribution.save(contributionToUpdate)
|
||||
|
||||
const event = new Event()
|
||||
|
||||
@ -252,4 +280,403 @@ export class ContributionResolver {
|
||||
|
||||
return new UnconfirmedContribution(contributionToUpdate, user, creations)
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION])
|
||||
@Mutation(() => [Number])
|
||||
async adminCreateContribution(
|
||||
@Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs,
|
||||
@Ctx() context: Context,
|
||||
): Promise<Decimal[]> {
|
||||
logger.info(
|
||||
`adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`,
|
||||
)
|
||||
const clientTimezoneOffset = getClientTimezoneOffset(context)
|
||||
if (!isValidDateString(creationDate)) {
|
||||
logger.error(`invalid Date for creationDate=${creationDate}`)
|
||||
throw new Error(`invalid Date for creationDate=${creationDate}`)
|
||||
}
|
||||
const emailContact = await UserContact.findOne({
|
||||
where: { email },
|
||||
withDeleted: true,
|
||||
relations: ['user'],
|
||||
})
|
||||
if (!emailContact) {
|
||||
logger.error(`Could not find user with email: ${email}`)
|
||||
throw new Error(`Could not find user with email: ${email}`)
|
||||
}
|
||||
if (emailContact.deletedAt) {
|
||||
logger.error('This emailContact was deleted. Cannot create a contribution.')
|
||||
throw new Error('This emailContact was deleted. Cannot create a contribution.')
|
||||
}
|
||||
if (emailContact.user.deletedAt) {
|
||||
logger.error('This user was deleted. Cannot create a contribution.')
|
||||
throw new Error('This user was deleted. Cannot create a contribution.')
|
||||
}
|
||||
if (!emailContact.emailChecked) {
|
||||
logger.error('Contribution could not be saved, Email is not activated')
|
||||
throw new Error('Contribution could not be saved, Email is not activated')
|
||||
}
|
||||
|
||||
const event = new Event()
|
||||
const moderator = getUser(context)
|
||||
logger.trace('moderator: ', moderator.id)
|
||||
const creations = await getUserCreation(emailContact.userId, clientTimezoneOffset)
|
||||
logger.trace('creations:', creations)
|
||||
const creationDateObj = new Date(creationDate)
|
||||
logger.trace('creationDateObj:', creationDateObj)
|
||||
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
|
||||
const contribution = DbContribution.create()
|
||||
contribution.userId = emailContact.userId
|
||||
contribution.amount = amount
|
||||
contribution.createdAt = new Date()
|
||||
contribution.contributionDate = creationDateObj
|
||||
contribution.memo = memo
|
||||
contribution.moderatorId = moderator.id
|
||||
contribution.contributionType = ContributionType.ADMIN
|
||||
contribution.contributionStatus = ContributionStatus.PENDING
|
||||
|
||||
logger.trace('contribution to save', contribution)
|
||||
|
||||
await DbContribution.save(contribution)
|
||||
|
||||
const eventAdminCreateContribution = new EventAdminContributionCreate()
|
||||
eventAdminCreateContribution.userId = moderator.id
|
||||
eventAdminCreateContribution.amount = amount
|
||||
eventAdminCreateContribution.contributionId = contribution.id
|
||||
await eventProtocol.writeEvent(
|
||||
event.setEventAdminContributionCreate(eventAdminCreateContribution),
|
||||
)
|
||||
|
||||
return getUserCreation(emailContact.userId, clientTimezoneOffset)
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS])
|
||||
@Mutation(() => AdminCreateContributions)
|
||||
async adminCreateContributions(
|
||||
@Arg('pendingCreations', () => [AdminCreateContributionArgs])
|
||||
contributions: AdminCreateContributionArgs[],
|
||||
@Ctx() context: Context,
|
||||
): Promise<AdminCreateContributions> {
|
||||
let success = false
|
||||
const successfulContribution: string[] = []
|
||||
const failedContribution: string[] = []
|
||||
for (const contribution of contributions) {
|
||||
await this.adminCreateContribution(contribution, context)
|
||||
.then(() => {
|
||||
successfulContribution.push(contribution.email)
|
||||
success = true
|
||||
})
|
||||
.catch(() => {
|
||||
failedContribution.push(contribution.email)
|
||||
})
|
||||
}
|
||||
return {
|
||||
success,
|
||||
successfulContribution,
|
||||
failedContribution,
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.ADMIN_UPDATE_CONTRIBUTION])
|
||||
@Mutation(() => AdminUpdateContribution)
|
||||
async adminUpdateContribution(
|
||||
@Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs,
|
||||
@Ctx() context: Context,
|
||||
): Promise<AdminUpdateContribution> {
|
||||
const clientTimezoneOffset = getClientTimezoneOffset(context)
|
||||
const emailContact = await UserContact.findOne({
|
||||
where: { email },
|
||||
withDeleted: true,
|
||||
relations: ['user'],
|
||||
})
|
||||
if (!emailContact) {
|
||||
logger.error(`Could not find UserContact with email: ${email}`)
|
||||
throw new Error(`Could not find UserContact with email: ${email}`)
|
||||
}
|
||||
const user = emailContact.user
|
||||
if (!user) {
|
||||
logger.error(`Could not find User to emailContact: ${email}`)
|
||||
throw new Error(`Could not find User to emailContact: ${email}`)
|
||||
}
|
||||
if (user.deletedAt) {
|
||||
logger.error(`User was deleted (${email})`)
|
||||
throw new Error(`User was deleted (${email})`)
|
||||
}
|
||||
|
||||
const moderator = getUser(context)
|
||||
|
||||
const contributionToUpdate = await DbContribution.findOne({
|
||||
where: { id, confirmedAt: IsNull() },
|
||||
})
|
||||
if (!contributionToUpdate) {
|
||||
logger.error('No contribution found to given id.')
|
||||
throw new Error('No contribution found to given id.')
|
||||
}
|
||||
|
||||
if (contributionToUpdate.userId !== user.id) {
|
||||
logger.error('user of the pending contribution and send user does not correspond')
|
||||
throw new Error('user of the pending contribution and send user does not correspond')
|
||||
}
|
||||
|
||||
if (contributionToUpdate.moderatorId === null) {
|
||||
logger.error('An admin is not allowed to update a user contribution.')
|
||||
throw new Error('An admin is not allowed to update a user contribution.')
|
||||
}
|
||||
|
||||
const creationDateObj = new Date(creationDate)
|
||||
let creations = await getUserCreation(user.id, clientTimezoneOffset)
|
||||
|
||||
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
|
||||
creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset)
|
||||
} else {
|
||||
logger.error('Currently the month of the contribution cannot change.')
|
||||
throw new Error('Currently the month of the contribution cannot change.')
|
||||
}
|
||||
|
||||
// all possible cases not to be true are thrown in this function
|
||||
validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
|
||||
contributionToUpdate.amount = amount
|
||||
contributionToUpdate.memo = memo
|
||||
contributionToUpdate.contributionDate = new Date(creationDate)
|
||||
contributionToUpdate.moderatorId = moderator.id
|
||||
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
|
||||
|
||||
await DbContribution.save(contributionToUpdate)
|
||||
|
||||
const result = new AdminUpdateContribution()
|
||||
result.amount = amount
|
||||
result.memo = contributionToUpdate.memo
|
||||
result.date = contributionToUpdate.contributionDate
|
||||
|
||||
result.creation = await getUserCreation(user.id, clientTimezoneOffset)
|
||||
|
||||
const event = new Event()
|
||||
const eventAdminContributionUpdate = new EventAdminContributionUpdate()
|
||||
eventAdminContributionUpdate.userId = user.id
|
||||
eventAdminContributionUpdate.amount = amount
|
||||
eventAdminContributionUpdate.contributionId = contributionToUpdate.id
|
||||
await eventProtocol.writeEvent(
|
||||
event.setEventAdminContributionUpdate(eventAdminContributionUpdate),
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS])
|
||||
@Query(() => [UnconfirmedContribution])
|
||||
async listUnconfirmedContributions(@Ctx() context: Context): Promise<UnconfirmedContribution[]> {
|
||||
const clientTimezoneOffset = getClientTimezoneOffset(context)
|
||||
const contributions = await getConnection()
|
||||
.createQueryBuilder()
|
||||
.select('c')
|
||||
.from(DbContribution, 'c')
|
||||
.leftJoinAndSelect('c.messages', 'm')
|
||||
.where({ confirmedAt: IsNull() })
|
||||
.getMany()
|
||||
|
||||
if (contributions.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const userIds = contributions.map((p) => p.userId)
|
||||
const userCreations = await getUserCreations(userIds, clientTimezoneOffset)
|
||||
const users = await DbUser.find({
|
||||
where: { id: In(userIds) },
|
||||
withDeleted: true,
|
||||
relations: ['emailContact'],
|
||||
})
|
||||
|
||||
return contributions.map((contribution) => {
|
||||
const user = users.find((u) => u.id === contribution.userId)
|
||||
const creation = userCreations.find((c) => c.id === contribution.userId)
|
||||
|
||||
return new UnconfirmedContribution(
|
||||
contribution,
|
||||
user,
|
||||
creation ? creation.creations : FULL_CREATION_AVAILABLE,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION])
|
||||
@Mutation(() => Boolean)
|
||||
async adminDeleteContribution(
|
||||
@Arg('id', () => Int) id: number,
|
||||
@Ctx() context: Context,
|
||||
): Promise<boolean> {
|
||||
const contribution = await DbContribution.findOne(id)
|
||||
if (!contribution) {
|
||||
logger.error(`Contribution not found for given id: ${id}`)
|
||||
throw new Error('Contribution not found for given id.')
|
||||
}
|
||||
const moderator = getUser(context)
|
||||
if (
|
||||
contribution.contributionType === ContributionType.USER &&
|
||||
contribution.userId === moderator.id
|
||||
) {
|
||||
throw new Error('Own contribution can not be deleted as admin')
|
||||
}
|
||||
const user = await DbUser.findOneOrFail(
|
||||
{ id: contribution.userId },
|
||||
{ relations: ['emailContact'] },
|
||||
)
|
||||
contribution.contributionStatus = ContributionStatus.DELETED
|
||||
contribution.deletedBy = moderator.id
|
||||
await contribution.save()
|
||||
const res = await contribution.softRemove()
|
||||
|
||||
const event = new Event()
|
||||
const eventAdminContributionDelete = new EventAdminContributionDelete()
|
||||
eventAdminContributionDelete.userId = contribution.userId
|
||||
eventAdminContributionDelete.amount = contribution.amount
|
||||
eventAdminContributionDelete.contributionId = contribution.id
|
||||
await eventProtocol.writeEvent(
|
||||
event.setEventAdminContributionDelete(eventAdminContributionDelete),
|
||||
)
|
||||
sendContributionRejectedEmail({
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.emailContact.email,
|
||||
language: user.language,
|
||||
senderFirstName: moderator.firstName,
|
||||
senderLastName: moderator.lastName,
|
||||
contributionMemo: contribution.memo,
|
||||
})
|
||||
|
||||
return !!res
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.CONFIRM_CONTRIBUTION])
|
||||
@Mutation(() => Boolean)
|
||||
async confirmContribution(
|
||||
@Arg('id', () => Int) id: number,
|
||||
@Ctx() context: Context,
|
||||
): Promise<boolean> {
|
||||
const clientTimezoneOffset = getClientTimezoneOffset(context)
|
||||
const contribution = await DbContribution.findOne(id)
|
||||
if (!contribution) {
|
||||
logger.error(`Contribution not found for given id: ${id}`)
|
||||
throw new Error('Contribution not found to given id.')
|
||||
}
|
||||
const moderatorUser = getUser(context)
|
||||
if (moderatorUser.id === contribution.userId) {
|
||||
logger.error('Moderator can not confirm own contribution')
|
||||
throw new Error('Moderator can not confirm own contribution')
|
||||
}
|
||||
const user = await DbUser.findOneOrFail(
|
||||
{ id: contribution.userId },
|
||||
{ withDeleted: true, relations: ['emailContact'] },
|
||||
)
|
||||
if (user.deletedAt) {
|
||||
logger.error('This user was deleted. Cannot confirm a contribution.')
|
||||
throw new Error('This user was deleted. Cannot confirm a contribution.')
|
||||
}
|
||||
const creations = await getUserCreation(contribution.userId, clientTimezoneOffset, false)
|
||||
validateContribution(
|
||||
creations,
|
||||
contribution.amount,
|
||||
contribution.contributionDate,
|
||||
clientTimezoneOffset,
|
||||
)
|
||||
|
||||
const receivedCallDate = new Date()
|
||||
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
await queryRunner.connect()
|
||||
await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED')
|
||||
try {
|
||||
const lastTransaction = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('transaction')
|
||||
.from(DbTransaction, 'transaction')
|
||||
.where('transaction.userId = :id', { id: contribution.userId })
|
||||
.orderBy('transaction.balanceDate', 'DESC')
|
||||
.getOne()
|
||||
logger.info('lastTransaction ID', lastTransaction ? lastTransaction.id : 'undefined')
|
||||
|
||||
let newBalance = new Decimal(0)
|
||||
let decay: Decay | null = null
|
||||
if (lastTransaction) {
|
||||
decay = calculateDecay(
|
||||
lastTransaction.balance,
|
||||
lastTransaction.balanceDate,
|
||||
receivedCallDate,
|
||||
)
|
||||
newBalance = decay.balance
|
||||
}
|
||||
newBalance = newBalance.add(contribution.amount.toString())
|
||||
|
||||
const transaction = new DbTransaction()
|
||||
transaction.typeId = TransactionTypeId.CREATION
|
||||
transaction.memo = contribution.memo
|
||||
transaction.userId = contribution.userId
|
||||
transaction.previous = lastTransaction ? lastTransaction.id : null
|
||||
transaction.amount = contribution.amount
|
||||
transaction.creationDate = contribution.contributionDate
|
||||
transaction.balance = newBalance
|
||||
transaction.balanceDate = receivedCallDate
|
||||
transaction.decay = decay ? decay.decay : new Decimal(0)
|
||||
transaction.decayStart = decay ? decay.start : null
|
||||
await queryRunner.manager.insert(DbTransaction, transaction)
|
||||
|
||||
contribution.confirmedAt = receivedCallDate
|
||||
contribution.confirmedBy = moderatorUser.id
|
||||
contribution.transactionId = transaction.id
|
||||
contribution.contributionStatus = ContributionStatus.CONFIRMED
|
||||
await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution)
|
||||
|
||||
await queryRunner.commitTransaction()
|
||||
logger.info('creation commited successfuly.')
|
||||
sendContributionConfirmedEmail({
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.emailContact.email,
|
||||
language: user.language,
|
||||
senderFirstName: moderatorUser.firstName,
|
||||
senderLastName: moderatorUser.lastName,
|
||||
contributionMemo: contribution.memo,
|
||||
contributionAmount: contribution.amount,
|
||||
})
|
||||
} catch (e) {
|
||||
await queryRunner.rollbackTransaction()
|
||||
logger.error(`Creation was not successful: ${e}`)
|
||||
throw new Error(`Creation was not successful.`)
|
||||
} finally {
|
||||
await queryRunner.release()
|
||||
}
|
||||
|
||||
const event = new Event()
|
||||
const eventContributionConfirm = new EventContributionConfirm()
|
||||
eventContributionConfirm.userId = user.id
|
||||
eventContributionConfirm.amount = contribution.amount
|
||||
eventContributionConfirm.contributionId = contribution.id
|
||||
await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm))
|
||||
return true
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.CREATION_TRANSACTION_LIST])
|
||||
@Query(() => ContributionListResult)
|
||||
async creationTransactionList(
|
||||
@Args()
|
||||
{ currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
|
||||
@Arg('userId', () => Int) userId: number,
|
||||
): Promise<ContributionListResult> {
|
||||
const offset = (currentPage - 1) * pageSize
|
||||
const [contributionResult, count] = await getConnection()
|
||||
.createQueryBuilder()
|
||||
.select('c')
|
||||
.from(DbContribution, 'c')
|
||||
.leftJoinAndSelect('c.user', 'u')
|
||||
.where(`user_id = ${userId}`)
|
||||
.limit(pageSize)
|
||||
.offset(offset)
|
||||
.orderBy('c.created_at', order)
|
||||
.getManyAndCount()
|
||||
|
||||
return new ContributionListResult(
|
||||
count,
|
||||
contributionResult.map((contribution) => new Contribution(contribution, contribution.user)),
|
||||
)
|
||||
// return userTransactions.map((t) => new Transaction(t, new User(user), communityUser))
|
||||
}
|
||||
}
|
||||
|
||||
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 CONFIG from '@/config'
|
||||
|
||||
import { GdtEntryList } from '@model/GdtEntryList'
|
||||
import Paginated from '@arg/Paginated'
|
||||
import { apiGet, apiPost } from '@/apis/HttpRequest'
|
||||
import { Order } from '@enum/Order'
|
||||
import Paginated from '@arg/Paginated'
|
||||
|
||||
import { Context, getUser } from '@/server/context'
|
||||
import CONFIG from '@/config'
|
||||
import { apiGet, apiPost } from '@/apis/HttpRequest'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
|
||||
@Resolver()
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { Resolver, Query, Authorized, Arg, Mutation, Args } from 'type-graphql'
|
||||
|
||||
import SubscribeNewsletterArgs from '@arg/SubscribeNewsletterArgs'
|
||||
|
||||
import {
|
||||
getKlickTippUser,
|
||||
getKlicktippTagMap,
|
||||
@ -6,7 +9,6 @@ import {
|
||||
klicktippSignIn,
|
||||
} from '@/apis/KlicktippController'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import SubscribeNewsletterArgs from '@arg/SubscribeNewsletterArgs'
|
||||
|
||||
@Resolver()
|
||||
export class KlicktippResolver {
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { Resolver, Query, Authorized } from 'type-graphql'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { CommunityStatistics } from '@model/CommunityStatistics'
|
||||
import { User as DbUser } from '@entity/User'
|
||||
import { Transaction as DbTransaction } from '@entity/Transaction'
|
||||
import { getConnection } from '@dbTools/typeorm'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { Resolver, Query, Authorized } from 'type-graphql'
|
||||
import { getConnection } from '@dbTools/typeorm'
|
||||
|
||||
import { Transaction as DbTransaction } from '@entity/Transaction'
|
||||
import { User as DbUser } from '@entity/User'
|
||||
|
||||
import { CommunityStatistics } from '@model/CommunityStatistics'
|
||||
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { calculateDecay } from '@/util/decay'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
@ -4,8 +4,12 @@
|
||||
import { transactionLinkCode } from './TransactionLinkResolver'
|
||||
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
|
||||
import { peterLustig } from '@/seeds/users/peter-lustig'
|
||||
import { cleanDB, testEnvironment } from '@test/helpers'
|
||||
import { cleanDB, testEnvironment, resetToken } from '@test/helpers'
|
||||
import { creationFactory } from '@/seeds/factory/creation'
|
||||
import { creations } from '@/seeds/creation/index'
|
||||
import { userFactory } from '@/seeds/factory/user'
|
||||
import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
|
||||
import { transactionLinks } from '@/seeds/transactionLink/index'
|
||||
import {
|
||||
login,
|
||||
createContributionLink,
|
||||
@ -13,17 +17,22 @@ import {
|
||||
createContribution,
|
||||
updateContribution,
|
||||
} from '@/seeds/graphql/mutations'
|
||||
import { listTransactionLinksAdmin } from '@/seeds/graphql/queries'
|
||||
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
||||
import { User } from '@entity/User'
|
||||
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { GraphQLError } from 'graphql'
|
||||
|
||||
let mutate: any, con: any
|
||||
let mutate: any, query: any, con: any
|
||||
let testEnv: any
|
||||
|
||||
let user: User
|
||||
|
||||
beforeAll(async () => {
|
||||
testEnv = await testEnvironment()
|
||||
mutate = testEnv.mutate
|
||||
query = testEnv.query
|
||||
con = testEnv.con
|
||||
await cleanDB()
|
||||
await userFactory(testEnv, bibiBloxberg)
|
||||
@ -36,6 +45,7 @@ afterAll(async () => {
|
||||
})
|
||||
|
||||
describe('TransactionLinkResolver', () => {
|
||||
// TODO: have this test separated into a transactionLink and a contributionLink part (if possible)
|
||||
describe('redeem daily Contribution Link', () => {
|
||||
const now = new Date()
|
||||
let contributionLink: DbContributionLink | undefined
|
||||
@ -223,6 +233,274 @@ describe('TransactionLinkResolver', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('transaction links list', () => {
|
||||
const variables = {
|
||||
userId: 1, // dummy, may be replaced
|
||||
filters: null,
|
||||
currentPage: 1,
|
||||
pageSize: 5,
|
||||
}
|
||||
|
||||
// TODO: there is a test not cleaning up after itself! Fix it!
|
||||
beforeAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('returns an error', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('401 Unauthorized')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated', () => {
|
||||
describe('without admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
it('returns an error', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('401 Unauthorized')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
// admin 'peter@lustig.de' has to exists for 'creationFactory'
|
||||
await userFactory(testEnv, peterLustig)
|
||||
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
variables.userId = user.id
|
||||
variables.pageSize = 25
|
||||
// bibi needs GDDs
|
||||
const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de')
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await creationFactory(testEnv, bibisCreation!)
|
||||
// bibis transaktion links
|
||||
const bibisTransaktionLinks = transactionLinks.filter(
|
||||
(transactionLink) => transactionLink.email === 'bibi@bloxberg.de',
|
||||
)
|
||||
for (let i = 0; i < bibisTransaktionLinks.length; i++) {
|
||||
await transactionLinkFactory(testEnv, bibisTransaktionLinks[i])
|
||||
}
|
||||
|
||||
// admin: only now log in
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
describe('without any filters', () => {
|
||||
it('finds 6 open transaction links and no deleted or redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 6,
|
||||
linkList: expect.not.arrayContaining([
|
||||
expect.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('all filters are null', () => {
|
||||
it('finds 6 open transaction links and no deleted or redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
...variables,
|
||||
filters: {
|
||||
withDeleted: null,
|
||||
withExpired: null,
|
||||
withRedeemed: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 6,
|
||||
linkList: expect.not.arrayContaining([
|
||||
expect.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter with deleted', () => {
|
||||
it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
...variables,
|
||||
filters: {
|
||||
withDeleted: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 7,
|
||||
linkList: expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter by expired', () => {
|
||||
it('finds 5 open transaction links, 1 expired, and no redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
...variables,
|
||||
filters: {
|
||||
withExpired: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 7,
|
||||
linkList: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.not.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory
|
||||
describe.skip('filter by redeemed', () => {
|
||||
it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
...variables,
|
||||
filters: {
|
||||
withDeleted: null,
|
||||
withExpired: null,
|
||||
withRedeemed: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 6,
|
||||
linkList: expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
memo: 'Yeah, eingelöst!',
|
||||
redeemedAt: expect.any(String),
|
||||
redeemedBy: expect.any(Number),
|
||||
}),
|
||||
expect.not.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactionLinkCode', () => {
|
||||
|
||||
@ -1,45 +1,36 @@
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
|
||||
import { getConnection } from '@dbTools/typeorm'
|
||||
import {
|
||||
Resolver,
|
||||
Args,
|
||||
Arg,
|
||||
Authorized,
|
||||
Ctx,
|
||||
Mutation,
|
||||
Query,
|
||||
Int,
|
||||
createUnionType,
|
||||
} from 'type-graphql'
|
||||
import { TransactionLink } from '@model/TransactionLink'
|
||||
import { ContributionLink } from '@model/ContributionLink'
|
||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
||||
import { Transaction as DbTransaction } from '@entity/Transaction'
|
||||
import { User as dbUser } from '@entity/User'
|
||||
import TransactionLinkArgs from '@arg/TransactionLinkArgs'
|
||||
import Paginated from '@arg/Paginated'
|
||||
import { calculateBalance } from '@/util/validate'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { randomBytes } from 'crypto'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
import { getConnection, MoreThan, FindOperator } from '@dbTools/typeorm'
|
||||
|
||||
import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink'
|
||||
import { User as DbUser } from '@entity/User'
|
||||
import { Transaction as DbTransaction } from '@entity/Transaction'
|
||||
import { Contribution as DbContribution } from '@entity/Contribution'
|
||||
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
||||
|
||||
import { User } from '@model/User'
|
||||
import { calculateDecay } from '@/util/decay'
|
||||
import { executeTransaction } from './TransactionResolver'
|
||||
import { ContributionLink } from '@model/ContributionLink'
|
||||
import { Decay } from '@model/Decay'
|
||||
import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
|
||||
import { Order } from '@enum/Order'
|
||||
import { ContributionType } from '@enum/ContributionType'
|
||||
import { ContributionStatus } from '@enum/ContributionStatus'
|
||||
import { Contribution as DbContribution } from '@entity/Contribution'
|
||||
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
||||
import { getUserCreation, validateContribution } from './util/creations'
|
||||
import { Decay } from '@model/Decay'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||
import { ContributionCycleType } from '@enum/ContributionCycleType'
|
||||
import TransactionLinkArgs from '@arg/TransactionLinkArgs'
|
||||
import Paginated from '@arg/Paginated'
|
||||
import TransactionLinkFilters from '@arg/TransactionLinkFilters'
|
||||
|
||||
const QueryLinkResult = createUnionType({
|
||||
name: 'QueryLinkResult', // the name of the GraphQL union
|
||||
types: () => [TransactionLink, ContributionLink] as const, // function that returns tuple of object types classes
|
||||
})
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
|
||||
import { Resolver, Args, Arg, Authorized, Ctx, Mutation, Query, Int } from 'type-graphql'
|
||||
import { calculateBalance } from '@/util/validate'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { calculateDecay } from '@/util/decay'
|
||||
import { getUserCreation, validateContribution } from './util/creations'
|
||||
import { executeTransaction } from './TransactionResolver'
|
||||
import QueryLinkResult from '@union/QueryLinkResult'
|
||||
|
||||
// TODO: do not export, test it inside the resolver
|
||||
export const transactionLinkCode = (date: Date): string => {
|
||||
@ -79,7 +70,7 @@ export class TransactionLinkResolver {
|
||||
throw new Error("user hasn't enough GDD or amount is < 0")
|
||||
}
|
||||
|
||||
const transactionLink = dbTransactionLink.create()
|
||||
const transactionLink = DbTransactionLink.create()
|
||||
transactionLink.userId = user.id
|
||||
transactionLink.amount = amount
|
||||
transactionLink.memo = memo
|
||||
@ -87,7 +78,7 @@ export class TransactionLinkResolver {
|
||||
transactionLink.code = transactionLinkCode(createdDate)
|
||||
transactionLink.createdAt = createdDate
|
||||
transactionLink.validUntil = validUntil
|
||||
await dbTransactionLink.save(transactionLink).catch(() => {
|
||||
await DbTransactionLink.save(transactionLink).catch(() => {
|
||||
throw new Error('Unable to save transaction link')
|
||||
})
|
||||
|
||||
@ -102,7 +93,7 @@ export class TransactionLinkResolver {
|
||||
): Promise<boolean> {
|
||||
const user = getUser(context)
|
||||
|
||||
const transactionLink = await dbTransactionLink.findOne({ id })
|
||||
const transactionLink = await DbTransactionLink.findOne({ id })
|
||||
if (!transactionLink) {
|
||||
throw new Error('Transaction Link not found!')
|
||||
}
|
||||
@ -132,11 +123,11 @@ export class TransactionLinkResolver {
|
||||
)
|
||||
return new ContributionLink(contributionLink)
|
||||
} else {
|
||||
const transactionLink = await dbTransactionLink.findOneOrFail({ code }, { withDeleted: true })
|
||||
const user = await dbUser.findOneOrFail({ id: transactionLink.userId })
|
||||
const transactionLink = await DbTransactionLink.findOneOrFail({ code }, { withDeleted: true })
|
||||
const user = await DbUser.findOneOrFail({ id: transactionLink.userId })
|
||||
let redeemedBy: User | null = null
|
||||
if (transactionLink && transactionLink.redeemedBy) {
|
||||
redeemedBy = new User(await dbUser.findOneOrFail({ id: transactionLink.redeemedBy }))
|
||||
redeemedBy = new User(await DbUser.findOneOrFail({ id: transactionLink.redeemedBy }))
|
||||
}
|
||||
return new TransactionLink(transactionLink, new User(user), redeemedBy)
|
||||
}
|
||||
@ -151,7 +142,7 @@ export class TransactionLinkResolver {
|
||||
): Promise<TransactionLink[]> {
|
||||
const user = getUser(context)
|
||||
// const now = new Date()
|
||||
const transactionLinks = await dbTransactionLink.find({
|
||||
const transactionLinks = await DbTransactionLink.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
redeemedBy: null,
|
||||
@ -321,8 +312,8 @@ export class TransactionLinkResolver {
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
const transactionLink = await dbTransactionLink.findOneOrFail({ code })
|
||||
const linkedUser = await dbUser.findOneOrFail(
|
||||
const transactionLink = await DbTransactionLink.findOneOrFail({ code })
|
||||
const linkedUser = await DbUser.findOneOrFail(
|
||||
{ id: transactionLink.userId },
|
||||
{ relations: ['emailContact'] },
|
||||
)
|
||||
@ -350,4 +341,44 @@ export class TransactionLinkResolver {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.LIST_TRANSACTION_LINKS_ADMIN])
|
||||
@Query(() => TransactionLinkResult)
|
||||
async listTransactionLinksAdmin(
|
||||
@Args()
|
||||
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
|
||||
@Arg('filters', () => TransactionLinkFilters, { nullable: true })
|
||||
filters: TransactionLinkFilters,
|
||||
@Arg('userId', () => Int)
|
||||
userId: number,
|
||||
): Promise<TransactionLinkResult> {
|
||||
const user = await DbUser.findOneOrFail({ id: userId })
|
||||
const where: {
|
||||
userId: number
|
||||
redeemedBy?: number | null
|
||||
validUntil?: FindOperator<Date> | null
|
||||
} = {
|
||||
userId,
|
||||
redeemedBy: null,
|
||||
validUntil: MoreThan(new Date()),
|
||||
}
|
||||
if (filters) {
|
||||
if (filters.withRedeemed) delete where.redeemedBy
|
||||
if (filters.withExpired) delete where.validUntil
|
||||
}
|
||||
const [transactionLinks, count] = await DbTransactionLink.findAndCount({
|
||||
where,
|
||||
withDeleted: filters ? filters.withDeleted : false,
|
||||
order: {
|
||||
createdAt: order,
|
||||
},
|
||||
skip: (currentPage - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
|
||||
return {
|
||||
linkCount: count,
|
||||
linkList: transactionLinks.map((tl) => new TransactionLink(tl, new User(user))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,38 +1,30 @@
|
||||
/* eslint-disable new-cap */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
|
||||
import { Context, getUser } from '@/server/context'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
|
||||
import { getCustomRepository, getConnection, In } from '@dbTools/typeorm'
|
||||
|
||||
import { Transaction } from '@model/Transaction'
|
||||
import { TransactionList } from '@model/TransactionList'
|
||||
|
||||
import TransactionSendArgs from '@arg/TransactionSendArgs'
|
||||
import Paginated from '@arg/Paginated'
|
||||
|
||||
import { Order } from '@enum/Order'
|
||||
|
||||
import { TransactionRepository } from '@repository/Transaction'
|
||||
import { TransactionLinkRepository } from '@repository/TransactionLink'
|
||||
|
||||
import { User as dbUser } from '@entity/User'
|
||||
import { Transaction as dbTransaction } from '@entity/Transaction'
|
||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
||||
import { TransactionRepository } from '@repository/Transaction'
|
||||
import { TransactionLinkRepository } from '@repository/TransactionLink'
|
||||
|
||||
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||
import { calculateBalance, isHexPublicKey } from '@/util/validate'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { User } from '@model/User'
|
||||
import { Transaction } from '@model/Transaction'
|
||||
import { TransactionList } from '@model/TransactionList'
|
||||
import { Order } from '@enum/Order'
|
||||
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||
import { calculateBalance } from '@/util/validate'
|
||||
import TransactionSendArgs from '@arg/TransactionSendArgs'
|
||||
import Paginated from '@arg/Paginated'
|
||||
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
import { Context, getUser } from '@/server/context'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { communityUser } from '@/util/communityUser'
|
||||
import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualTransactions'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
import { BalanceResolver } from './BalanceResolver'
|
||||
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
|
||||
import { findUserByEmail } from './UserResolver'
|
||||
import {
|
||||
sendTransactionLinkRedeemedEmail,
|
||||
sendTransactionReceivedEmail,
|
||||
@ -40,6 +32,10 @@ import {
|
||||
import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event'
|
||||
import { eventProtocol } from '@/event/EventProtocolEmitter'
|
||||
|
||||
import { BalanceResolver } from './BalanceResolver'
|
||||
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
|
||||
import { findUserByEmail } from './UserResolver'
|
||||
|
||||
export const executeTransaction = async (
|
||||
amount: Decimal,
|
||||
memo: string,
|
||||
@ -319,10 +315,6 @@ export class TransactionResolver {
|
||||
|
||||
// TODO this is subject to replay attacks
|
||||
const senderUser = getUser(context)
|
||||
if (senderUser.pubKey.length !== 32) {
|
||||
logger.error(`invalid sender public key:${senderUser.pubKey}`)
|
||||
throw new Error('invalid sender public key')
|
||||
}
|
||||
|
||||
// validate recipient user
|
||||
const recipientUser = await findUserByEmail(email)
|
||||
@ -335,10 +327,6 @@ export class TransactionResolver {
|
||||
logger.error(`The recipient account is not activated: recipientUser=${recipientUser}`)
|
||||
throw new Error('The recipient account is not activated')
|
||||
}
|
||||
if (!isHexPublicKey(recipientUser.pubKey.toString('hex'))) {
|
||||
logger.error(`invalid recipient public key: recipientUser=${recipientUser}`)
|
||||
throw new Error('invalid recipient public key')
|
||||
}
|
||||
|
||||
await executeTransaction(amount, memo, senderUser, recipientUser)
|
||||
logger.info(
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
import { objectValuesToArray } from '@/util/utilities'
|
||||
import { testEnvironment, headerPushMock, resetToken, cleanDB } from '@test/helpers'
|
||||
import { logger, i18n as localization } from '@test/testSetup'
|
||||
import { printTimeDuration } from '@/util/time'
|
||||
@ -15,8 +16,11 @@ import {
|
||||
updateUserInfos,
|
||||
createContribution,
|
||||
confirmContribution,
|
||||
setUserRole,
|
||||
deleteUser,
|
||||
unDeleteUser,
|
||||
} from '@/seeds/graphql/mutations'
|
||||
import { verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries'
|
||||
import { verifyLogin, queryOptIn, searchAdminUsers, searchUsers } from '@/seeds/graphql/queries'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { User } from '@entity/User'
|
||||
import CONFIG from '@/config'
|
||||
@ -25,7 +29,6 @@ import {
|
||||
sendAccountMultiRegistrationEmail,
|
||||
sendResetPasswordEmail,
|
||||
} from '@/emails/sendEmailVariants'
|
||||
import { activationLink } from './UserResolver'
|
||||
import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
|
||||
import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
|
||||
import { ContributionLink } from '@model/ContributionLink'
|
||||
@ -38,6 +41,8 @@ import { UserContact } from '@entity/UserContact'
|
||||
import { OptInType } from '../enum/OptInType'
|
||||
import { UserContactType } from '../enum/UserContactType'
|
||||
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
|
||||
import { stephenHawking } from '@/seeds/users/stephen-hawking'
|
||||
import { garrickOllivander } from '@/seeds/users/garrick-ollivander'
|
||||
import { encryptPassword } from '@/password/PasswordEncryptor'
|
||||
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
|
||||
import { SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
|
||||
@ -66,6 +71,8 @@ jest.mock('@/apis/KlicktippController', () => {
|
||||
})
|
||||
*/
|
||||
|
||||
let admin: User
|
||||
let user: User
|
||||
let mutate: any, query: any, con: any
|
||||
let testEnv: any
|
||||
|
||||
@ -131,12 +138,8 @@ describe('UserResolver', () => {
|
||||
firstName: 'Peter',
|
||||
lastName: 'Lustig',
|
||||
password: '0',
|
||||
pubKey: null,
|
||||
privKey: null,
|
||||
// emailHash: expect.any(Buffer),
|
||||
createdAt: expect.any(Date),
|
||||
// emailChecked: false,
|
||||
passphrase: expect.any(String),
|
||||
language: 'de',
|
||||
isAdmin: null,
|
||||
deletedAt: null,
|
||||
@ -807,12 +810,8 @@ describe('UserResolver', () => {
|
||||
})
|
||||
|
||||
describe('user exists in DB', () => {
|
||||
let emailContact: UserContact
|
||||
|
||||
beforeAll(async () => {
|
||||
await userFactory(testEnv, bibiBloxberg)
|
||||
// await resetEntity(LoginEmailOptIn)
|
||||
emailContact = await UserContact.findOneOrFail(variables)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@ -821,7 +820,7 @@ describe('UserResolver', () => {
|
||||
})
|
||||
|
||||
describe('duration not expired', () => {
|
||||
it('returns true', async () => {
|
||||
it('throws an error', async () => {
|
||||
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [
|
||||
@ -847,19 +846,19 @@ describe('UserResolver', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('sends reset password email', () => {
|
||||
expect(sendResetPasswordEmail).toBeCalledWith({
|
||||
firstName: 'Bibi',
|
||||
lastName: 'Bloxberg',
|
||||
email: 'bibi@bloxberg.de',
|
||||
language: 'de',
|
||||
resetLink: activationLink(emailContact.emailVerificationCode),
|
||||
timeDurationObject: expect.objectContaining({
|
||||
hours: expect.any(Number),
|
||||
minutes: expect.any(Number),
|
||||
}),
|
||||
it('sends reset password email', () => {
|
||||
expect(sendResetPasswordEmail).toBeCalledWith({
|
||||
firstName: 'Bibi',
|
||||
lastName: 'Bloxberg',
|
||||
email: 'bibi@bloxberg.de',
|
||||
language: 'de',
|
||||
resetLink: expect.any(String),
|
||||
timeDurationObject: expect.objectContaining({
|
||||
hours: expect.any(Number),
|
||||
minutes: expect.any(Number),
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1253,6 +1252,635 @@ describe('UserResolver', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('set user role', () => {
|
||||
// TODO: there is a test not cleaning up after itself! Fix it!
|
||||
beforeAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('returns an error', async () => {
|
||||
await expect(
|
||||
mutate({ mutation: setUserRole, variables: { userId: 1, isAdmin: true } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('401 Unauthorized')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated', () => {
|
||||
describe('without admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
it('returns an error', async () => {
|
||||
await expect(
|
||||
mutate({ mutation: setUserRole, variables: { userId: user.id + 1, isAdmin: true } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('401 Unauthorized')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
admin = await userFactory(testEnv, peterLustig)
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
describe('user to get a new role does not exist', () => {
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
await expect(
|
||||
mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('logs the error thrown', () => {
|
||||
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('change role with success', () => {
|
||||
beforeAll(async () => {
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
})
|
||||
|
||||
describe('user gets new role', () => {
|
||||
describe('to admin', () => {
|
||||
it('returns date string', async () => {
|
||||
const result = await mutate({
|
||||
mutation: setUserRole,
|
||||
variables: { userId: user.id, isAdmin: true },
|
||||
})
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
setUserRole: expect.any(String),
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(new Date(result.data.setUserRole)).toEqual(expect.any(Date))
|
||||
})
|
||||
})
|
||||
|
||||
describe('to usual user', () => {
|
||||
it('returns null', async () => {
|
||||
await expect(
|
||||
mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
setUserRole: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('change role with error', () => {
|
||||
describe('is own role', () => {
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
await expect(
|
||||
mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('Administrator can not change his own role!')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
it('logs the error thrown', () => {
|
||||
expect(logger.error).toBeCalledWith('Administrator can not change his own role!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('user has already role to be set', () => {
|
||||
describe('to admin', () => {
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
await mutate({
|
||||
mutation: setUserRole,
|
||||
variables: { userId: user.id, isAdmin: true },
|
||||
})
|
||||
await expect(
|
||||
mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: true } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('User is already admin!')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('logs the error thrown', () => {
|
||||
expect(logger.error).toBeCalledWith('User is already admin!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('to usual user', () => {
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
await mutate({
|
||||
mutation: setUserRole,
|
||||
variables: { userId: user.id, isAdmin: false },
|
||||
})
|
||||
await expect(
|
||||
mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('User is already a usual user!')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('logs the error thrown', () => {
|
||||
expect(logger.error).toBeCalledWith('User is already a usual user!')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete user', () => {
|
||||
describe('unauthenticated', () => {
|
||||
it('returns an error', async () => {
|
||||
await expect(mutate({ mutation: deleteUser, variables: { userId: 1 } })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('401 Unauthorized')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated', () => {
|
||||
describe('without admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
it('returns an error', async () => {
|
||||
await expect(
|
||||
mutate({ mutation: deleteUser, variables: { userId: user.id + 1 } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('401 Unauthorized')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
admin = await userFactory(testEnv, peterLustig)
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
describe('user to be deleted does not exist', () => {
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
await expect(
|
||||
mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('logs the error thrown', () => {
|
||||
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete self', () => {
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
await expect(
|
||||
mutate({ mutation: deleteUser, variables: { userId: admin.id } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('Moderator can not delete his own account!')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('logs the error thrown', () => {
|
||||
expect(logger.error).toBeCalledWith('Moderator can not delete his own account!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete with success', () => {
|
||||
beforeAll(async () => {
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
})
|
||||
|
||||
it('returns date string', async () => {
|
||||
const result = await mutate({ mutation: deleteUser, variables: { userId: user.id } })
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
deleteUser: expect.any(String),
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(new Date(result.data.deleteUser)).toEqual(expect.any(Date))
|
||||
})
|
||||
|
||||
describe('delete deleted user', () => {
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
await expect(
|
||||
mutate({ mutation: deleteUser, variables: { userId: user.id } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError(`Could not find user with userId: ${user.id}`)],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('logs the error thrown', () => {
|
||||
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('unDelete user', () => {
|
||||
describe('unauthenticated', () => {
|
||||
it('returns an error', async () => {
|
||||
await expect(mutate({ mutation: unDeleteUser, variables: { userId: 1 } })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('401 Unauthorized')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated', () => {
|
||||
describe('without admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
it('returns an error', async () => {
|
||||
await expect(
|
||||
mutate({ mutation: unDeleteUser, variables: { userId: user.id + 1 } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('401 Unauthorized')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
admin = await userFactory(testEnv, peterLustig)
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
describe('user to be undelete does not exist', () => {
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
await expect(
|
||||
mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('logs the error thrown', () => {
|
||||
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('user to undelete is not deleted', () => {
|
||||
beforeAll(async () => {
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
})
|
||||
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
await expect(
|
||||
mutate({ mutation: unDeleteUser, variables: { userId: user.id } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('User is not deleted')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('logs the error thrown', () => {
|
||||
expect(logger.error).toBeCalledWith('User is not deleted')
|
||||
})
|
||||
|
||||
describe('undelete deleted user', () => {
|
||||
beforeAll(async () => {
|
||||
await mutate({ mutation: deleteUser, variables: { userId: user.id } })
|
||||
})
|
||||
|
||||
it('returns null', async () => {
|
||||
await expect(
|
||||
mutate({ mutation: unDeleteUser, variables: { userId: user.id } }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: { unDeleteUser: null },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('search users', () => {
|
||||
const variablesWithoutTextAndFilters = {
|
||||
searchText: '',
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
filters: null,
|
||||
}
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('returns an error', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: searchUsers,
|
||||
variables: {
|
||||
...variablesWithoutTextAndFilters,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('401 Unauthorized')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated', () => {
|
||||
describe('without admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
it('returns an error', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: searchUsers,
|
||||
variables: {
|
||||
...variablesWithoutTextAndFilters,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('401 Unauthorized')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with admin rights', () => {
|
||||
const allUsers = {
|
||||
bibi: expect.objectContaining({
|
||||
email: 'bibi@bloxberg.de',
|
||||
}),
|
||||
garrick: expect.objectContaining({
|
||||
email: 'garrick@ollivander.com',
|
||||
}),
|
||||
peter: expect.objectContaining({
|
||||
email: 'peter@lustig.de',
|
||||
}),
|
||||
stephen: expect.objectContaining({
|
||||
email: 'stephen@hawking.uk',
|
||||
}),
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
admin = await userFactory(testEnv, peterLustig)
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||
})
|
||||
|
||||
await userFactory(testEnv, bibiBloxberg)
|
||||
await userFactory(testEnv, stephenHawking)
|
||||
await userFactory(testEnv, garrickOllivander)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
describe('without any filters', () => {
|
||||
it('finds all users', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: searchUsers,
|
||||
variables: {
|
||||
...variablesWithoutTextAndFilters,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
searchUsers: {
|
||||
userCount: 4,
|
||||
userList: expect.arrayContaining(objectValuesToArray(allUsers)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('all filters are null', () => {
|
||||
it('finds all users', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: searchUsers,
|
||||
variables: {
|
||||
...variablesWithoutTextAndFilters,
|
||||
filters: {
|
||||
byActivated: null,
|
||||
byDeleted: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
searchUsers: {
|
||||
userCount: 4,
|
||||
userList: expect.arrayContaining(objectValuesToArray(allUsers)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter by unchecked email', () => {
|
||||
it('finds only users with unchecked email', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: searchUsers,
|
||||
variables: {
|
||||
...variablesWithoutTextAndFilters,
|
||||
filters: {
|
||||
byActivated: false,
|
||||
byDeleted: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
searchUsers: {
|
||||
userCount: 1,
|
||||
userList: expect.arrayContaining([allUsers.garrick]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter by deleted users', () => {
|
||||
it('finds only users with deleted account', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: searchUsers,
|
||||
variables: {
|
||||
...variablesWithoutTextAndFilters,
|
||||
filters: {
|
||||
byActivated: null,
|
||||
byDeleted: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
searchUsers: {
|
||||
userCount: 1,
|
||||
userList: expect.arrayContaining([allUsers.stephen]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter by deleted account and unchecked email', () => {
|
||||
it('finds no users', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: searchUsers,
|
||||
variables: {
|
||||
...variablesWithoutTextAndFilters,
|
||||
filters: {
|
||||
byActivated: false,
|
||||
byDeleted: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
searchUsers: {
|
||||
userCount: 0,
|
||||
userList: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('printTimeDuration', () => {
|
||||
|
||||
@ -1,28 +1,50 @@
|
||||
import fs from 'fs'
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
import i18n from 'i18n'
|
||||
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
|
||||
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import {
|
||||
Resolver,
|
||||
Query,
|
||||
Args,
|
||||
Arg,
|
||||
Authorized,
|
||||
Ctx,
|
||||
UseMiddleware,
|
||||
Mutation,
|
||||
Int,
|
||||
} from 'type-graphql'
|
||||
import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm'
|
||||
import CONFIG from '@/config'
|
||||
import { User } from '@model/User'
|
||||
|
||||
import { User as DbUser } from '@entity/User'
|
||||
import { UserContact as DbUserContact } from '@entity/UserContact'
|
||||
import { communityDbUser } from '@/util/communityUser'
|
||||
import { getTimeDurationObject, printTimeDuration } from '@/util/time'
|
||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
||||
import { ContributionLink as dbContributionLink } from '@entity/ContributionLink'
|
||||
import { encode } from '@/auth/JWT'
|
||||
import CreateUserArgs from '@arg/CreateUserArgs'
|
||||
import UnsecureLoginArgs from '@arg/UnsecureLoginArgs'
|
||||
import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
|
||||
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
|
||||
import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink'
|
||||
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
||||
import { UserRepository } from '@repository/User'
|
||||
|
||||
import { User } from '@model/User'
|
||||
import { SearchAdminUsersResult } from '@model/AdminUser'
|
||||
import { UserAdmin, SearchUsersResult } from '@model/UserAdmin'
|
||||
import { OptInType } from '@enum/OptInType'
|
||||
import { Order } from '@enum/Order'
|
||||
import { UserContactType } from '@enum/UserContactType'
|
||||
|
||||
import {
|
||||
sendAccountActivationEmail,
|
||||
sendAccountMultiRegistrationEmail,
|
||||
sendResetPasswordEmail,
|
||||
} from '@/emails/sendEmailVariants'
|
||||
|
||||
import { getTimeDurationObject, printTimeDuration } from '@/util/time'
|
||||
import CreateUserArgs from '@arg/CreateUserArgs'
|
||||
import UnsecureLoginArgs from '@arg/UnsecureLoginArgs'
|
||||
import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
|
||||
import Paginated from '@arg/Paginated'
|
||||
import SearchUsersArgs from '@arg/SearchUsersArgs'
|
||||
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
|
||||
import CONFIG from '@/config'
|
||||
import { communityDbUser } from '@/util/communityUser'
|
||||
import { encode } from '@/auth/JWT'
|
||||
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
|
||||
import { klicktippSignIn } from '@/apis/KlicktippController'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { hasElopageBuys } from '@/util/hasElopageBuys'
|
||||
@ -36,14 +58,9 @@ import {
|
||||
EventSendConfirmationEmail,
|
||||
EventActivateAccount,
|
||||
} from '@/event/Event'
|
||||
import { getUserCreation } from './util/creations'
|
||||
import { UserContactType } from '../enum/UserContactType'
|
||||
import { UserRepository } from '@/typeorm/repository/User'
|
||||
import { SearchAdminUsersResult } from '@model/AdminUser'
|
||||
import Paginated from '@arg/Paginated'
|
||||
import { Order } from '@enum/Order'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { isValidPassword, SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
|
||||
import { getUserCreation, getUserCreations } from './util/creations'
|
||||
import { isValidPassword } from '@/password/EncryptorUtils'
|
||||
import { FULL_CREATION_AVAILABLE } from './const/const'
|
||||
import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor'
|
||||
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
|
||||
|
||||
@ -58,89 +75,6 @@ const isLanguage = (language: string): boolean => {
|
||||
return LANGUAGES.includes(language)
|
||||
}
|
||||
|
||||
const PHRASE_WORD_COUNT = 24
|
||||
const WORDS = fs
|
||||
.readFileSync('src/config/mnemonic.uncompressed_buffer13116.txt')
|
||||
.toString()
|
||||
.split(',')
|
||||
const PassphraseGenerate = (): string[] => {
|
||||
logger.trace('PassphraseGenerate...')
|
||||
const result = []
|
||||
for (let i = 0; i < PHRASE_WORD_COUNT; i++) {
|
||||
result.push(WORDS[sodium.randombytes_random() % 2048])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => {
|
||||
logger.trace('KeyPairEd25519Create...')
|
||||
if (!passphrase.length || passphrase.length < PHRASE_WORD_COUNT) {
|
||||
logger.error('passphrase empty or to short')
|
||||
throw new Error('passphrase empty or to short')
|
||||
}
|
||||
|
||||
const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES)
|
||||
sodium.crypto_hash_sha512_init(state)
|
||||
|
||||
// To prevent breaking existing passphrase-hash combinations word indices will be put into 64 Bit Variable to mimic first implementation of algorithms
|
||||
for (let i = 0; i < PHRASE_WORD_COUNT; i++) {
|
||||
const value = Buffer.alloc(8)
|
||||
const wordIndex = WORDS.indexOf(passphrase[i])
|
||||
value.writeBigInt64LE(BigInt(wordIndex))
|
||||
sodium.crypto_hash_sha512_update(state, value)
|
||||
}
|
||||
// trailing space is part of the login_server implementation
|
||||
const clearPassphrase = passphrase.join(' ') + ' '
|
||||
sodium.crypto_hash_sha512_update(state, Buffer.from(clearPassphrase))
|
||||
const outputHashBuffer = Buffer.alloc(sodium.crypto_hash_sha512_BYTES)
|
||||
sodium.crypto_hash_sha512_final(state, outputHashBuffer)
|
||||
|
||||
const pubKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
|
||||
const privKey = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES)
|
||||
|
||||
sodium.crypto_sign_seed_keypair(
|
||||
pubKey,
|
||||
privKey,
|
||||
outputHashBuffer.slice(0, sodium.crypto_sign_SEEDBYTES),
|
||||
)
|
||||
logger.debug(`KeyPair creation ready. pubKey=${pubKey}`)
|
||||
|
||||
return [pubKey, privKey]
|
||||
}
|
||||
|
||||
/*
|
||||
const getEmailHash = (email: string): Buffer => {
|
||||
logger.trace('getEmailHash...')
|
||||
const emailHash = Buffer.alloc(sodium.crypto_generichash_BYTES)
|
||||
sodium.crypto_generichash(emailHash, Buffer.from(email))
|
||||
logger.debug(`getEmailHash...successful: ${emailHash}`)
|
||||
return emailHash
|
||||
}
|
||||
*/
|
||||
|
||||
const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => {
|
||||
logger.trace('SecretKeyCryptographyEncrypt...')
|
||||
const encrypted = Buffer.alloc(message.length + sodium.crypto_secretbox_MACBYTES)
|
||||
const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES)
|
||||
nonce.fill(31) // static nonce
|
||||
|
||||
sodium.crypto_secretbox_easy(encrypted, message, nonce, encryptionKey)
|
||||
logger.debug(`SecretKeyCryptographyEncrypt...successful: ${encrypted}`)
|
||||
return encrypted
|
||||
}
|
||||
|
||||
const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: Buffer): Buffer => {
|
||||
logger.trace('SecretKeyCryptographyDecrypt...')
|
||||
const message = Buffer.alloc(encryptedMessage.length - sodium.crypto_secretbox_MACBYTES)
|
||||
const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES)
|
||||
nonce.fill(31) // static nonce
|
||||
|
||||
sodium.crypto_secretbox_open_easy(message, encryptedMessage, nonce, encryptionKey)
|
||||
|
||||
logger.debug(`SecretKeyCryptographyDecrypt...successful: ${message}`)
|
||||
return message
|
||||
}
|
||||
|
||||
const newEmailContact = (email: string, userId: number): DbUserContact => {
|
||||
logger.trace(`newEmailContact...`)
|
||||
const emailContact = new DbUserContact()
|
||||
@ -153,91 +87,6 @@ const newEmailContact = (email: string, userId: number): DbUserContact => {
|
||||
logger.debug(`newEmailContact...successful: ${emailContact}`)
|
||||
return emailContact
|
||||
}
|
||||
/*
|
||||
const newEmailOptIn = (userId: number): LoginEmailOptIn => {
|
||||
logger.trace('newEmailOptIn...')
|
||||
const emailOptIn = new LoginEmailOptIn()
|
||||
emailOptIn.verificationCode = random(64)
|
||||
emailOptIn.userId = userId
|
||||
emailOptIn.emailOptInTypeId = OptInType.EMAIL_OPT_IN_REGISTER
|
||||
logger.debug(`newEmailOptIn...successful: ${emailOptIn}`)
|
||||
return emailOptIn
|
||||
}
|
||||
*/
|
||||
/*
|
||||
// needed by AdminResolver
|
||||
// checks if given code exists and can be resent
|
||||
// if optIn does not exits, it is created
|
||||
export const checkOptInCode = async (
|
||||
optInCode: LoginEmailOptIn | undefined,
|
||||
user: DbUser,
|
||||
optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
|
||||
): Promise<LoginEmailOptIn> => {
|
||||
logger.info(`checkOptInCode... ${optInCode}`)
|
||||
if (optInCode) {
|
||||
if (!canResendOptIn(optInCode)) {
|
||||
logger.error(
|
||||
`email already sent less than ${printTimeDuration(
|
||||
CONFIG.EMAIL_CODE_REQUEST_TIME,
|
||||
)} minutes ago`,
|
||||
)
|
||||
throw new Error(
|
||||
`email already sent less than ${printTimeDuration(
|
||||
CONFIG.EMAIL_CODE_REQUEST_TIME,
|
||||
)} minutes ago`,
|
||||
)
|
||||
}
|
||||
optInCode.updatedAt = new Date()
|
||||
optInCode.resendCount++
|
||||
} else {
|
||||
logger.trace('create new OptIn for userId=' + user.id)
|
||||
optInCode = newEmailOptIn(user.id)
|
||||
}
|
||||
|
||||
if (user.emailChecked) {
|
||||
optInCode.emailOptInTypeId = optInType
|
||||
}
|
||||
await LoginEmailOptIn.save(optInCode).catch(() => {
|
||||
logger.error('Unable to save optin code= ' + optInCode)
|
||||
throw new Error('Unable to save optin code.')
|
||||
})
|
||||
logger.debug(`checkOptInCode...successful: ${optInCode} for userid=${user.id}`)
|
||||
return optInCode
|
||||
}
|
||||
*/
|
||||
export const checkEmailVerificationCode = async (
|
||||
emailContact: DbUserContact,
|
||||
optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
|
||||
): Promise<DbUserContact> => {
|
||||
logger.info(`checkEmailVerificationCode... ${emailContact}`)
|
||||
if (emailContact.updatedAt) {
|
||||
if (!canEmailResend(emailContact.updatedAt)) {
|
||||
logger.error(
|
||||
`email already sent less than ${printTimeDuration(
|
||||
CONFIG.EMAIL_CODE_REQUEST_TIME,
|
||||
)} minutes ago`,
|
||||
)
|
||||
throw new Error(
|
||||
`email already sent less than ${printTimeDuration(
|
||||
CONFIG.EMAIL_CODE_REQUEST_TIME,
|
||||
)} minutes ago`,
|
||||
)
|
||||
}
|
||||
emailContact.updatedAt = new Date()
|
||||
emailContact.emailResendCount++
|
||||
} else {
|
||||
logger.trace('create new EmailVerificationCode for userId=' + emailContact.userId)
|
||||
emailContact.emailChecked = false
|
||||
emailContact.emailVerificationCode = random(64)
|
||||
}
|
||||
emailContact.emailOptInTypeId = optInType
|
||||
await DbUserContact.save(emailContact).catch(() => {
|
||||
logger.error('Unable to save email verification code= ' + emailContact)
|
||||
throw new Error('Unable to save email verification code.')
|
||||
})
|
||||
logger.debug(`checkEmailVerificationCode...successful: ${emailContact}`)
|
||||
return emailContact
|
||||
}
|
||||
|
||||
export const activationLink = (verificationCode: BigInt): string => {
|
||||
logger.debug(`activationLink(${verificationCode})...`)
|
||||
@ -268,7 +117,6 @@ export class UserResolver {
|
||||
const clientTimezoneOffset = getClientTimezoneOffset(context)
|
||||
const userEntity = getUser(context)
|
||||
const user = new User(userEntity, await getUserCreation(userEntity.id, clientTimezoneOffset))
|
||||
// user.pubkey = userEntity.pubKey.toString('hex')
|
||||
// Elopage Status & Stored PublisherId
|
||||
user.hasElopage = await this.hasElopage(context)
|
||||
|
||||
@ -300,11 +148,6 @@ export class UserResolver {
|
||||
// TODO we want to catch this on the frontend and ask the user to check his emails or resend code
|
||||
throw new Error('User has no password set yet')
|
||||
}
|
||||
if (!dbUser.pubKey || !dbUser.privKey) {
|
||||
logger.error('The User has no private or publicKey.')
|
||||
// TODO we want to catch this on the frontend and ask the user to check his emails or resend code
|
||||
throw new Error('User has no private or publicKey')
|
||||
}
|
||||
|
||||
if (!verifyPassword(dbUser, password)) {
|
||||
logger.error('The User has no valid credentials.')
|
||||
@ -336,7 +179,7 @@ export class UserResolver {
|
||||
|
||||
context.setHeaders.push({
|
||||
key: 'token',
|
||||
value: encode(dbUser.pubKey),
|
||||
value: encode(dbUser.gradidoID),
|
||||
})
|
||||
const ev = new EventLogin()
|
||||
ev.userId = user.id
|
||||
@ -348,6 +191,7 @@ export class UserResolver {
|
||||
@Authorized([RIGHTS.LOGOUT])
|
||||
@Mutation(() => String)
|
||||
async logout(): Promise<boolean> {
|
||||
// TODO: Event still missing here!!
|
||||
// TODO: We dont need this anymore, but might need this in the future in oder to invalidate a valid JWT-Token.
|
||||
// Furthermore this hook can be useful for tracking user behaviour (did he logout or not? Warn him if he didn't on next login)
|
||||
// The functionality is fully client side - the client just needs to delete his token with the current implementation.
|
||||
@ -428,11 +272,6 @@ export class UserResolver {
|
||||
}
|
||||
}
|
||||
|
||||
const passphrase = PassphraseGenerate()
|
||||
// const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
|
||||
// const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
|
||||
// const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
|
||||
// const emailHash = getEmailHash(email)
|
||||
const gradidoID = await newGradidoID()
|
||||
|
||||
const eventRegister = new EventRegister()
|
||||
@ -446,11 +285,10 @@ export class UserResolver {
|
||||
dbUser.language = language
|
||||
dbUser.publisherId = publisherId
|
||||
dbUser.passwordEncryptionType = PasswordEncryptionType.NO_PASSWORD
|
||||
dbUser.passphrase = passphrase.join(' ')
|
||||
logger.debug('new dbUser=' + dbUser)
|
||||
if (redeemCode) {
|
||||
if (redeemCode.match(/^CL-/)) {
|
||||
const contributionLink = await dbContributionLink.findOne({
|
||||
const contributionLink = await DbContributionLink.findOne({
|
||||
code: redeemCode.replace('CL-', ''),
|
||||
})
|
||||
logger.info('redeemCode found contributionLink=' + contributionLink)
|
||||
@ -459,7 +297,7 @@ export class UserResolver {
|
||||
eventRedeemRegister.contributionId = contributionLink.id
|
||||
}
|
||||
} else {
|
||||
const transactionLink = await dbTransactionLink.findOne({ code: redeemCode })
|
||||
const transactionLink = await DbTransactionLink.findOne({ code: redeemCode })
|
||||
logger.info('redeemCode found transactionLink=' + transactionLink)
|
||||
if (transactionLink) {
|
||||
dbUser.referrerId = transactionLink.userId
|
||||
@ -467,12 +305,6 @@ export class UserResolver {
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO this field has no null allowed unlike the loginServer table
|
||||
// dbUser.pubKey = Buffer.from(randomBytes(32)) // Buffer.alloc(32, 0) default to 0000...
|
||||
// dbUser.pubkey = keyPair[0]
|
||||
// loginUser.password = passwordHash[0].readBigUInt64LE() // using the shorthash
|
||||
// loginUser.pubKey = keyPair[0]
|
||||
// loginUser.privKey = encryptedPrivkey
|
||||
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
await queryRunner.connect()
|
||||
@ -561,32 +393,45 @@ export class UserResolver {
|
||||
return true
|
||||
}
|
||||
|
||||
// can be both types: REGISTER and RESET_PASSWORD
|
||||
// let optInCode = await LoginEmailOptIn.findOne({
|
||||
// userId: user.id,
|
||||
// })
|
||||
// let optInCode = user.emailContact.emailVerificationCode
|
||||
const dbUserContact = await checkEmailVerificationCode(
|
||||
user.emailContact,
|
||||
OptInType.EMAIL_OPT_IN_RESET_PASSWORD,
|
||||
)
|
||||
if (!canEmailResend(user.emailContact.updatedAt || user.emailContact.createdAt)) {
|
||||
logger.error(
|
||||
`email already sent less than ${printTimeDuration(
|
||||
CONFIG.EMAIL_CODE_REQUEST_TIME,
|
||||
)} minutes ago`,
|
||||
)
|
||||
throw new Error(
|
||||
`email already sent less than ${printTimeDuration(
|
||||
CONFIG.EMAIL_CODE_REQUEST_TIME,
|
||||
)} minutes ago`,
|
||||
)
|
||||
}
|
||||
|
||||
// optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD)
|
||||
logger.info(`optInCode for ${email}=${dbUserContact}`)
|
||||
user.emailContact.updatedAt = new Date()
|
||||
user.emailContact.emailResendCount++
|
||||
user.emailContact.emailVerificationCode = random(64)
|
||||
user.emailContact.emailOptInTypeId = OptInType.EMAIL_OPT_IN_RESET_PASSWORD
|
||||
await user.emailContact.save().catch(() => {
|
||||
logger.error('Unable to save email verification code= ' + user.emailContact)
|
||||
throw new Error('Unable to save email verification code.')
|
||||
})
|
||||
|
||||
logger.info(`optInCode for ${email}=${user.emailContact}`)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const emailSent = await sendResetPasswordEmail({
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email,
|
||||
language: user.language,
|
||||
resetLink: activationLink(dbUserContact.emailVerificationCode),
|
||||
resetLink: activationLink(user.emailContact.emailVerificationCode),
|
||||
timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME),
|
||||
})
|
||||
|
||||
/* uncomment this, when you need the activation link on the console */
|
||||
// In case EMails are disabled log the activation link for the user
|
||||
if (!emailSent) {
|
||||
logger.debug(`Reset password link: ${activationLink(dbUserContact.emailVerificationCode)}`)
|
||||
logger.debug(
|
||||
`Reset password link: ${activationLink(user.emailContact.emailVerificationCode)}`,
|
||||
)
|
||||
}
|
||||
logger.info(`forgotPassword(${email}) successful...`)
|
||||
|
||||
@ -624,7 +469,7 @@ export class UserResolver {
|
||||
})
|
||||
logger.debug('userContact loaded...')
|
||||
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
|
||||
if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
|
||||
if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) {
|
||||
logger.error(
|
||||
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
|
||||
)
|
||||
@ -638,34 +483,12 @@ export class UserResolver {
|
||||
const user = userContact.user
|
||||
logger.debug('user with EmailVerificationCode found...')
|
||||
|
||||
// Generate Passphrase if needed
|
||||
if (!user.passphrase) {
|
||||
const passphrase = PassphraseGenerate()
|
||||
user.passphrase = passphrase.join(' ')
|
||||
logger.debug('new Passphrase generated...')
|
||||
}
|
||||
|
||||
const passphrase = user.passphrase.split(' ')
|
||||
if (passphrase.length < PHRASE_WORD_COUNT) {
|
||||
logger.error('Could not load a correct passphrase')
|
||||
// TODO if this can happen we cannot recover from that
|
||||
// this seem to be good on production data, if we dont
|
||||
// make a coding mistake we do not have a problem here
|
||||
throw new Error('Could not load a correct passphrase')
|
||||
}
|
||||
logger.debug('Passphrase is valid...')
|
||||
|
||||
// Activate EMail
|
||||
userContact.emailChecked = true
|
||||
|
||||
// Update Password
|
||||
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
|
||||
const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash
|
||||
const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
|
||||
const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
|
||||
user.password = encryptPassword(user, password)
|
||||
user.pubKey = keyPair[0]
|
||||
user.privKey = encryptedPrivkey
|
||||
logger.debug('User credentials updated ...')
|
||||
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
@ -728,7 +551,7 @@ export class UserResolver {
|
||||
const userContact = await DbUserContact.findOneOrFail({ emailVerificationCode: optIn })
|
||||
logger.debug(`found optInCode=${userContact}`)
|
||||
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
|
||||
if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
|
||||
if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) {
|
||||
logger.error(
|
||||
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
|
||||
)
|
||||
@ -776,30 +599,14 @@ export class UserResolver {
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: This had some error cases defined - like missing private key. This is no longer checked.
|
||||
const oldPasswordHash = SecretKeyCryptographyCreateKey(
|
||||
userEntity.emailContact.email,
|
||||
password,
|
||||
)
|
||||
if (!verifyPassword(userEntity, password)) {
|
||||
logger.error(`Old password is invalid`)
|
||||
throw new Error(`Old password is invalid`)
|
||||
}
|
||||
|
||||
const privKey = SecretKeyCryptographyDecrypt(userEntity.privKey, oldPasswordHash[1])
|
||||
logger.debug('oldPassword decrypted...')
|
||||
const newPasswordHash = SecretKeyCryptographyCreateKey(
|
||||
userEntity.emailContact.email,
|
||||
passwordNew,
|
||||
) // return short and long hash
|
||||
logger.debug('newPasswordHash created...')
|
||||
const encryptedPrivkey = SecretKeyCryptographyEncrypt(privKey, newPasswordHash[1])
|
||||
logger.debug('PrivateKey encrypted...')
|
||||
|
||||
// Save new password hash and newly encrypted private key
|
||||
userEntity.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
|
||||
userEntity.password = encryptPassword(userEntity, passwordNew)
|
||||
userEntity.privKey = encryptedPrivkey
|
||||
}
|
||||
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
@ -864,6 +671,206 @@ export class UserResolver {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.SEARCH_USERS])
|
||||
@Query(() => SearchUsersResult)
|
||||
async searchUsers(
|
||||
@Args()
|
||||
{ searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs,
|
||||
@Ctx() context: Context,
|
||||
): Promise<SearchUsersResult> {
|
||||
const clientTimezoneOffset = getClientTimezoneOffset(context)
|
||||
const userRepository = getCustomRepository(UserRepository)
|
||||
const userFields = [
|
||||
'id',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'emailId',
|
||||
'emailContact',
|
||||
'deletedAt',
|
||||
'isAdmin',
|
||||
]
|
||||
const [users, count] = await userRepository.findBySearchCriteriaPagedFiltered(
|
||||
userFields.map((fieldName) => {
|
||||
return 'user.' + fieldName
|
||||
}),
|
||||
searchText,
|
||||
filters,
|
||||
currentPage,
|
||||
pageSize,
|
||||
)
|
||||
|
||||
if (users.length === 0) {
|
||||
return {
|
||||
userCount: 0,
|
||||
userList: [],
|
||||
}
|
||||
}
|
||||
|
||||
const creations = await getUserCreations(
|
||||
users.map((u) => u.id),
|
||||
clientTimezoneOffset,
|
||||
)
|
||||
|
||||
const adminUsers = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
let emailConfirmationSend = ''
|
||||
if (!user.emailContact.emailChecked) {
|
||||
if (user.emailContact.updatedAt) {
|
||||
emailConfirmationSend = user.emailContact.updatedAt.toISOString()
|
||||
} else {
|
||||
emailConfirmationSend = user.emailContact.createdAt.toISOString()
|
||||
}
|
||||
}
|
||||
const userCreations = creations.find((c) => c.id === user.id)
|
||||
const adminUser = new UserAdmin(
|
||||
user,
|
||||
userCreations ? userCreations.creations : FULL_CREATION_AVAILABLE,
|
||||
await hasElopageBuys(user.emailContact.email),
|
||||
emailConfirmationSend,
|
||||
)
|
||||
return adminUser
|
||||
}),
|
||||
)
|
||||
return {
|
||||
userCount: count,
|
||||
userList: adminUsers,
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.SET_USER_ROLE])
|
||||
@Mutation(() => Date, { nullable: true })
|
||||
async setUserRole(
|
||||
@Arg('userId', () => Int)
|
||||
userId: number,
|
||||
@Arg('isAdmin', () => Boolean)
|
||||
isAdmin: boolean,
|
||||
@Ctx()
|
||||
context: Context,
|
||||
): Promise<Date | null> {
|
||||
const user = await DbUser.findOne({ id: userId })
|
||||
// user exists ?
|
||||
if (!user) {
|
||||
logger.error(`Could not find user with userId: ${userId}`)
|
||||
throw new Error(`Could not find user with userId: ${userId}`)
|
||||
}
|
||||
// administrator user changes own role?
|
||||
const moderatorUser = getUser(context)
|
||||
if (moderatorUser.id === userId) {
|
||||
logger.error('Administrator can not change his own role!')
|
||||
throw new Error('Administrator can not change his own role!')
|
||||
}
|
||||
// change isAdmin
|
||||
switch (user.isAdmin) {
|
||||
case null:
|
||||
if (isAdmin === true) {
|
||||
user.isAdmin = new Date()
|
||||
} else {
|
||||
logger.error('User is already a usual user!')
|
||||
throw new Error('User is already a usual user!')
|
||||
}
|
||||
break
|
||||
default:
|
||||
if (isAdmin === false) {
|
||||
user.isAdmin = null
|
||||
} else {
|
||||
logger.error('User is already admin!')
|
||||
throw new Error('User is already admin!')
|
||||
}
|
||||
break
|
||||
}
|
||||
await user.save()
|
||||
const newUser = await DbUser.findOne({ id: userId })
|
||||
return newUser ? newUser.isAdmin : null
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.DELETE_USER])
|
||||
@Mutation(() => Date, { nullable: true })
|
||||
async deleteUser(
|
||||
@Arg('userId', () => Int) userId: number,
|
||||
@Ctx() context: Context,
|
||||
): Promise<Date | null> {
|
||||
const user = await DbUser.findOne({ id: userId })
|
||||
// user exists ?
|
||||
if (!user) {
|
||||
logger.error(`Could not find user with userId: ${userId}`)
|
||||
throw new Error(`Could not find user with userId: ${userId}`)
|
||||
}
|
||||
// moderator user disabled own account?
|
||||
const moderatorUser = getUser(context)
|
||||
if (moderatorUser.id === userId) {
|
||||
logger.error('Moderator can not delete his own account!')
|
||||
throw new Error('Moderator can not delete his own account!')
|
||||
}
|
||||
// soft-delete user
|
||||
await user.softRemove()
|
||||
const newUser = await DbUser.findOne({ id: userId }, { withDeleted: true })
|
||||
return newUser ? newUser.deletedAt : null
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.UNDELETE_USER])
|
||||
@Mutation(() => Date, { nullable: true })
|
||||
async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise<Date | null> {
|
||||
const user = await DbUser.findOne({ id: userId }, { withDeleted: true })
|
||||
if (!user) {
|
||||
logger.error(`Could not find user with userId: ${userId}`)
|
||||
throw new Error(`Could not find user with userId: ${userId}`)
|
||||
}
|
||||
if (!user.deletedAt) {
|
||||
logger.error('User is not deleted')
|
||||
throw new Error('User is not deleted')
|
||||
}
|
||||
await user.recover()
|
||||
return null
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.SEND_ACTIVATION_EMAIL])
|
||||
@Mutation(() => Boolean)
|
||||
async sendActivationEmail(@Arg('email') email: string): Promise<boolean> {
|
||||
email = email.trim().toLowerCase()
|
||||
// const user = await dbUser.findOne({ id: emailContact.userId })
|
||||
const user = await findUserByEmail(email)
|
||||
if (!user) {
|
||||
logger.error(`Could not find User to emailContact: ${email}`)
|
||||
throw new Error(`Could not find User to emailContact: ${email}`)
|
||||
}
|
||||
if (user.deletedAt) {
|
||||
logger.error(`User with emailContact: ${email} is deleted.`)
|
||||
throw new Error(`User with emailContact: ${email} is deleted.`)
|
||||
}
|
||||
const emailContact = user.emailContact
|
||||
if (emailContact.deletedAt) {
|
||||
logger.error(`The emailContact: ${email} of this User is deleted.`)
|
||||
throw new Error(`The emailContact: ${email} of this User is deleted.`)
|
||||
}
|
||||
|
||||
emailContact.emailResendCount++
|
||||
await emailContact.save()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const emailSent = await sendAccountActivationEmail({
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email,
|
||||
language: user.language,
|
||||
activationLink: activationLink(emailContact.emailVerificationCode),
|
||||
timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME),
|
||||
})
|
||||
|
||||
// In case EMails are disabled log the activation link for the user
|
||||
if (!emailSent) {
|
||||
logger.info(`Account confirmation link: ${activationLink}`)
|
||||
} else {
|
||||
const event = new Event()
|
||||
const eventSendConfirmationEmail = new EventSendConfirmationEmail()
|
||||
eventSendConfirmationEmail.userId = user.id
|
||||
await eventProtocol.writeEvent(
|
||||
event.setEventSendConfirmationEmail(eventSendConfirmationEmail),
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export async function findUserByEmail(email: string): Promise<DbUser> {
|
||||
@ -904,10 +911,7 @@ const isOptInValid = (optIn: LoginEmailOptIn): boolean => {
|
||||
return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME)
|
||||
}
|
||||
*/
|
||||
const isEmailVerificationCodeValid = (updatedAt: Date | null): boolean => {
|
||||
if (updatedAt == null) {
|
||||
return true
|
||||
}
|
||||
const isEmailVerificationCodeValid = (updatedAt: Date): boolean => {
|
||||
return isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_VALID_TIME)
|
||||
}
|
||||
/*
|
||||
|
||||
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
|
||||
})
|
||||
@ -4,21 +4,6 @@ import { User as DbUser } from '@entity/User'
|
||||
|
||||
@EntityRepository(DbUser)
|
||||
export class UserRepository extends Repository<DbUser> {
|
||||
async findByPubkeyHex(pubkeyHex: string): Promise<DbUser> {
|
||||
const dbUser = await this.createQueryBuilder('user')
|
||||
.leftJoinAndSelect('user.emailContact', 'emailContact')
|
||||
.where('hex(user.pubKey) = :pubkeyHex', { pubkeyHex })
|
||||
.getOneOrFail()
|
||||
/*
|
||||
const dbUser = await this.findOneOrFail(`hex(user.pubKey) = { pubkeyHex }`)
|
||||
const emailContact = await this.query(
|
||||
`SELECT * from user_contacts where id = { dbUser.emailId }`,
|
||||
)
|
||||
dbUser.emailContact = emailContact
|
||||
*/
|
||||
return dbUser
|
||||
}
|
||||
|
||||
async findBySearchCriteriaPagedFiltered(
|
||||
select: string[],
|
||||
searchCriteria: string,
|
||||
|
||||
@ -16,8 +16,6 @@ const communityDbUser: dbUser = {
|
||||
emailId: -1,
|
||||
firstName: 'Gradido',
|
||||
lastName: 'Akademie',
|
||||
pubKey: Buffer.from(''),
|
||||
privKey: Buffer.from(''),
|
||||
deletedAt: null,
|
||||
password: BigInt(0),
|
||||
// emailHash: Buffer.from(''),
|
||||
@ -26,7 +24,6 @@ const communityDbUser: dbUser = {
|
||||
language: '',
|
||||
isAdmin: null,
|
||||
publisherId: 0,
|
||||
passphrase: '',
|
||||
// default password encryption type
|
||||
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
|
||||
hasId: function (): boolean {
|
||||
|
||||
@ -14,10 +14,6 @@ function isStringBoolean(value: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function isHexPublicKey(publicKey: string): boolean {
|
||||
return /^[0-9A-Fa-f]{64}$/i.test(publicKey)
|
||||
}
|
||||
|
||||
async function calculateBalance(
|
||||
userId: number,
|
||||
amount: Decimal,
|
||||
@ -45,4 +41,4 @@ async function calculateBalance(
|
||||
return { balance, lastTransactionId: lastTransaction.id, decay }
|
||||
}
|
||||
|
||||
export { isHexPublicKey, calculateBalance, isStringBoolean }
|
||||
export { calculateBalance, isStringBoolean }
|
||||
|
||||
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 { initialize } from '@dbTools/helpers'
|
||||
import { entities } from '@entity/index'
|
||||
import { i18n, logger } from './testSetup'
|
||||
|
||||
export const headerPushMock = jest.fn((t) => {
|
||||
context.token = t.value
|
||||
@ -26,8 +27,8 @@ export const cleanDB = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
export const testEnvironment = async (logger?: any, localization?: any) => {
|
||||
const server = await createServer(context, logger, localization)
|
||||
export const testEnvironment = async (testLogger: any = logger, testI18n: any = i18n) => {
|
||||
const server = await createServer(context, testLogger, testI18n)
|
||||
const con = server.con
|
||||
const testClient = createTestClient(server.apollo)
|
||||
const mutate = testClient.mutate
|
||||
|
||||
@ -51,6 +51,7 @@
|
||||
"@arg/*": ["src/graphql/arg/*"],
|
||||
"@enum/*": ["src/graphql/enum/*"],
|
||||
"@model/*": ["src/graphql/model/*"],
|
||||
"@union/*": ["src/graphql/union/*"],
|
||||
"@repository/*": ["src/typeorm/repository/*"],
|
||||
"@test/*": ["test/*"],
|
||||
/* external */
|
||||
|
||||
112
database/entity/0057-clear_old_password_junk/User.ts
Normal file
112
database/entity/0057-clear_old_password_junk/User.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
DeleteDateColumn,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
} from 'typeorm'
|
||||
import { Contribution } from '../Contribution'
|
||||
import { ContributionMessage } from '../ContributionMessage'
|
||||
import { UserContact } from '../UserContact'
|
||||
|
||||
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
|
||||
export class User extends BaseEntity {
|
||||
@PrimaryGeneratedColumn('increment', { unsigned: true })
|
||||
id: number
|
||||
|
||||
@Column({
|
||||
name: 'gradido_id',
|
||||
length: 36,
|
||||
nullable: false,
|
||||
collation: 'utf8mb4_unicode_ci',
|
||||
})
|
||||
gradidoID: string
|
||||
|
||||
@Column({
|
||||
name: 'alias',
|
||||
length: 20,
|
||||
nullable: true,
|
||||
default: null,
|
||||
collation: 'utf8mb4_unicode_ci',
|
||||
})
|
||||
alias: string
|
||||
|
||||
@OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user)
|
||||
@JoinColumn({ name: 'email_id' })
|
||||
emailContact: UserContact
|
||||
|
||||
@Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null })
|
||||
emailId: number | null
|
||||
|
||||
@Column({
|
||||
name: 'first_name',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
default: null,
|
||||
collation: 'utf8mb4_unicode_ci',
|
||||
})
|
||||
firstName: string
|
||||
|
||||
@Column({
|
||||
name: 'last_name',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
default: null,
|
||||
collation: 'utf8mb4_unicode_ci',
|
||||
})
|
||||
lastName: string
|
||||
|
||||
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false })
|
||||
createdAt: Date
|
||||
|
||||
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
|
||||
deletedAt: Date | null
|
||||
|
||||
@Column({ type: 'bigint', default: 0, unsigned: true })
|
||||
password: BigInt
|
||||
|
||||
@Column({
|
||||
name: 'password_encryption_type',
|
||||
type: 'int',
|
||||
unsigned: true,
|
||||
nullable: false,
|
||||
default: 0,
|
||||
})
|
||||
passwordEncryptionType: number
|
||||
|
||||
@Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false })
|
||||
language: string
|
||||
|
||||
@Column({ name: 'is_admin', type: 'datetime', nullable: true, default: null })
|
||||
isAdmin: Date | null
|
||||
|
||||
@Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null })
|
||||
referrerId?: number | null
|
||||
|
||||
@Column({
|
||||
name: 'contribution_link_id',
|
||||
type: 'int',
|
||||
unsigned: true,
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
contributionLinkId?: number | null
|
||||
|
||||
@Column({ name: 'publisher_id', default: 0 })
|
||||
publisherId: number
|
||||
|
||||
@OneToMany(() => Contribution, (contribution) => contribution.user)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
contributions?: Contribution[]
|
||||
|
||||
@OneToMany(() => ContributionMessage, (message) => message.user)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
messages?: ContributionMessage[]
|
||||
|
||||
@OneToMany(() => UserContact, (userContact: UserContact) => userContact.user)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
userContacts?: UserContact[]
|
||||
}
|
||||
57
database/entity/0057-clear_old_password_junk/UserContact.ts
Normal file
57
database/entity/0057-clear_old_password_junk/UserContact.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
DeleteDateColumn,
|
||||
OneToOne,
|
||||
} from 'typeorm'
|
||||
import { User } from './User'
|
||||
|
||||
@Entity('user_contacts', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
|
||||
export class UserContact extends BaseEntity {
|
||||
@PrimaryGeneratedColumn('increment', { unsigned: true })
|
||||
id: number
|
||||
|
||||
@Column({
|
||||
name: 'type',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
default: null,
|
||||
collation: 'utf8mb4_unicode_ci',
|
||||
})
|
||||
type: string
|
||||
|
||||
@OneToOne(() => User, (user) => user.emailContact)
|
||||
user: User
|
||||
|
||||
@Column({ name: 'user_id', type: 'int', unsigned: true, nullable: false })
|
||||
userId: number
|
||||
|
||||
@Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' })
|
||||
email: string
|
||||
|
||||
@Column({ name: 'email_verification_code', type: 'bigint', unsigned: true, unique: true })
|
||||
emailVerificationCode: BigInt
|
||||
|
||||
@Column({ name: 'email_opt_in_type_id' })
|
||||
emailOptInTypeId: number
|
||||
|
||||
@Column({ name: 'email_resend_count' })
|
||||
emailResendCount: number
|
||||
|
||||
@Column({ name: 'email_checked', type: 'bool', nullable: false, default: false })
|
||||
emailChecked: boolean
|
||||
|
||||
@Column({ length: 255, unique: false, nullable: true, collation: 'utf8mb4_unicode_ci' })
|
||||
phone: string
|
||||
|
||||
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false })
|
||||
createdAt: Date
|
||||
|
||||
@Column({ name: 'updated_at', nullable: true, default: null, type: 'datetime' })
|
||||
updatedAt: Date | null
|
||||
|
||||
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
|
||||
deletedAt: Date | null
|
||||
}
|
||||
@ -1 +1 @@
|
||||
export { User } from './0053-change_password_encryption/User'
|
||||
export { User } from './0057-clear_old_password_junk/User'
|
||||
|
||||
@ -1 +1 @@
|
||||
export { UserContact } from './0053-change_password_encryption/UserContact'
|
||||
export { UserContact } from './0057-clear_old_password_junk/UserContact'
|
||||
|
||||
39
database/migrations/0056-consistent_transactions_table.ts
Normal file
39
database/migrations/0056-consistent_transactions_table.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/* MIGRATION TO add users that have a transaction but do not exist */
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { OkPacket } from 'mysql'
|
||||
|
||||
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
|
||||
const missingUserIds = await queryFn(`
|
||||
SELECT user_id FROM transactions
|
||||
WHERE NOT EXISTS (SELECT id FROM users WHERE id = user_id) GROUP BY user_id;`)
|
||||
|
||||
for (let i = 0; i < missingUserIds.length; i++) {
|
||||
let gradidoId = ''
|
||||
let countIds: any[] = []
|
||||
do {
|
||||
gradidoId = uuidv4()
|
||||
countIds = await queryFn(
|
||||
`SELECT COUNT(*) FROM \`users\` WHERE \`gradido_id\` = "${gradidoId}"`,
|
||||
)
|
||||
} while (countIds[0] > 0)
|
||||
|
||||
const userContact = (await queryFn(`
|
||||
INSERT INTO user_contacts
|
||||
(type, user_id, email, email_checked, created_at, deleted_at)
|
||||
VALUES
|
||||
('EMAIL', ${missingUserIds[i].user_id}, 'deleted.user${missingUserIds[i].user_id}@gradido.net', 0, NOW(), NOW());`)) as unknown as OkPacket
|
||||
|
||||
await queryFn(`
|
||||
INSERT INTO users
|
||||
(id, gradido_id, email_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language)
|
||||
VALUES
|
||||
(${missingUserIds[i].user_id}, '${gradidoId}', ${userContact.insertId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`)
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {}
|
||||
16
database/migrations/0057-clear_old_password_junk.ts
Normal file
16
database/migrations/0057-clear_old_password_junk.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
|
||||
await queryFn('ALTER TABLE users DROP COLUMN public_key;')
|
||||
await queryFn('ALTER TABLE users DROP COLUMN privkey;')
|
||||
await queryFn('ALTER TABLE users DROP COLUMN email_hash;')
|
||||
await queryFn('ALTER TABLE users DROP COLUMN passphrase;')
|
||||
}
|
||||
|
||||
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
|
||||
await queryFn('ALTER TABLE users ADD COLUMN public_key binary(32) DEFAULT NULL;')
|
||||
await queryFn('ALTER TABLE users ADD COLUMN privkey binary(80) DEFAULT NULL;')
|
||||
await queryFn('ALTER TABLE users ADD COLUMN email_hash binary(32) DEFAULT NULL;')
|
||||
await queryFn('ALTER TABLE users ADD COLUMN passphrase text DEFAULT NULL;')
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gradido-database",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"description": "Gradido Database Tool to execute database migrations",
|
||||
"main": "src/index.ts",
|
||||
"repository": "https://github.com/gradido/gradido/database",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bootstrap-vue-gradido-wallet",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node run/server.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gradido",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"description": "Gradido",
|
||||
"main": "index.js",
|
||||
"repository": "git@github.com:gradido/gradido.git",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user