Merge pull request #3255 from gradido/wiedervorlage_v2

feat(admin): wiedervorlage v2
This commit is contained in:
einhornimmond 2023-12-01 17:03:50 +01:00 committed by GitHub
commit 41c83c930b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 963 additions and 327 deletions

View File

@ -9,7 +9,7 @@ module.exports = {
],
coverageThreshold: {
global: {
lines: 97,
lines: 96,
},
},
moduleFileExtensions: [

View File

@ -120,7 +120,18 @@ describe('ContributionMessagesFormular', () => {
text: 'text form message',
},
})
await wrapper.find('button[data-test="submit-moderator"]').trigger('click')
// choose tab
// tabs: text | moderator | memo
// 0 | 1 | 2
await wrapper
.find('div[data-test="message-type-tabs"]')
.findAll('.nav-item a')
.at(1)
.trigger('click')
// click save
await wrapper.find('button[data-test="submit-dialog"]').trigger('click')
})
it('moderatorMesage has `MODERATOR`', () => {
@ -152,7 +163,7 @@ describe('ContributionMessagesFormular', () => {
resubmissionDate: futureDate,
resubmissionTime: '08:46',
})
await wrapper.find('button[data-test="submit-moderator"]').trigger('click')
await wrapper.find('button[data-test="submit-dialog"]').trigger('click')
})
it('graphql payload contain resubmission date', () => {
@ -164,7 +175,7 @@ describe('ContributionMessagesFormular', () => {
variables: {
contributionId: 42,
message: 'text form message',
messageType: 'MODERATOR',
messageType: 'DIALOG',
resubmissionAt: futureDateExactTime.toString(),
},
})
@ -177,13 +188,20 @@ describe('ContributionMessagesFormular', () => {
describe('set memo', () => {
beforeEach(async () => {
await wrapper.setData({
chatOrMemo: 0,
})
await wrapper.find('button[data-test="submit-memo"]').trigger('click')
// choose tab
// tabs: text | moderator | memo
// 0 | 1 | 2
await wrapper
.find('div[data-test="message-type-tabs"]')
.findAll('.nav-item a')
.at(2)
.trigger('click')
// click save
await wrapper.find('button[data-test="submit-dialog"]').trigger('click')
})
it('check chatOrMemo value is 1', () => {
expect(wrapper.vm.chatOrMemo).toBe(1)
it('check tabindex value is 2', () => {
expect(wrapper.vm.tabindex).toBe(2)
})
})
@ -193,7 +211,7 @@ describe('ContributionMessagesFormular', () => {
form: {
memo: 'changed memo',
},
chatOrMemo: 1,
tabindex: 2,
})
await wrapper.find('button[data-test="submit-dialog"]').trigger('click')
})
@ -204,6 +222,7 @@ describe('ContributionMessagesFormular', () => {
variables: {
id: 42,
memo: 'changed memo',
resubmissionAt: null,
},
})
})

View File

@ -1,18 +1,24 @@
<template>
<div class="contribution-messages-formular">
<div class="mt-5">
<b-form @reset.prevent="onReset" @submit="onSubmit(messageType.DIALOG)">
<b-tabs content-class="mt-3" v-model="chatOrMemo">
<b-tab :title="$t('moderator.chat')" active>
<b-form-group>
<b-form-checkbox v-model="showResubmissionDate">
{{ $t('moderator.show-submission-form') }}
</b-form-checkbox>
</b-form-group>
<b-form-group v-if="showResubmissionDate">
<b-form-datepicker v-model="resubmissionDate"></b-form-datepicker>
<time-picker v-model="resubmissionTime"></time-picker>
</b-form-group>
<b-form @reset.prevent="onReset" @submit="onSubmit()">
<b-form-group>
<b-form-checkbox v-model="showResubmissionDate">
{{ $t('moderator.show-submission-form') }}
</b-form-checkbox>
</b-form-group>
<b-form-group v-if="showResubmissionDate">
<b-form-datepicker v-model="resubmissionDate" :min="now"></b-form-datepicker>
<time-picker v-model="resubmissionTime"></time-picker>
</b-form-group>
<b-tabs content-class="mt-3" v-model="tabindex" data-test="message-type-tabs">
<b-tab active>
<template #title>
<span id="message-tab-title">{{ $t('moderator.message') }}</span>
<b-tooltip target="message-tab-title" triggers="hover">
{{ $t('moderator.message-tooltip') }}
</b-tooltip>
</template>
<b-form-textarea
id="textarea"
v-model="form.text"
@ -20,7 +26,27 @@
rows="3"
></b-form-textarea>
</b-tab>
<b-tab :title="$t('moderator.memo')">
<b-tab>
<template #title>
<span id="notice-tab-title">{{ $t('moderator.notice') }}</span>
<b-tooltip target="notice-tab-title" triggers="hover">
{{ $t('moderator.notice-tooltip') }}
</b-tooltip>
</template>
<b-form-textarea
id="textarea"
v-model="form.text"
:placeholder="$t('moderator.notice')"
rows="3"
></b-form-textarea>
</b-tab>
<b-tab>
<template #title>
<span id="memo-tab-title">{{ $t('moderator.memo') }}</span>
<b-tooltip target="memo-tab-title" triggers="hover">
{{ $t('moderator.memo-tooltip') }}
</b-tooltip>
</template>
<b-form-textarea
id="textarea"
v-model="form.memo"
@ -33,37 +59,15 @@
<b-col>
<b-button type="reset" variant="danger">{{ $t('form.cancel') }}</b-button>
</b-col>
<b-col class="text-center">
<b-button
type="button"
variant="warning"
class="text-black"
@click.prevent="enableMemo()"
data-test="submit-memo"
>
{{ $t('moderator.memo-modify') }}
</b-button>
<b-button
type="button"
variant="warning"
class="text-black"
:disabled="moderatorDisabled"
@click.prevent="onSubmit(messageType.MODERATOR)"
data-test="submit-moderator"
>
{{ $t('moderator.notice') }}
</b-button>
</b-col>
<b-col class="text-right">
<b-button
type="submit"
variant="primary"
:disabled="disabled"
@click.prevent="onSubmit(messageType.DIALOG)"
@click.prevent="onSubmit()"
data-test="submit-dialog"
>
{{ $t('form.submit') }}
{{ $t('save') }}
</b-button>
</b-col>
</b-row>
@ -94,18 +98,31 @@ export default {
type: Boolean,
required: true,
},
inputResubmissionDate: {
type: String,
required: false,
},
},
data() {
const localInputResubmissionDate = this.inputResubmissionDate
? new Date(this.inputResubmissionDate)
: null
return {
form: {
text: '',
memo: this.contributionMemo,
},
loading: false,
resubmissionDate: null,
resubmissionTime: '00:00',
showResubmissionDate: false,
chatOrMemo: 0, // 0 = Chat, 1 = Memo
resubmissionDate: localInputResubmissionDate,
resubmissionTime: localInputResubmissionDate
? localInputResubmissionDate.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})
: '00:00',
showResubmissionDate: localInputResubmissionDate !== null,
tabindex: 0, // 0 = Chat, 1 = Notice, 2 = Memo
messageType: {
DIALOG: 'DIALOG',
MODERATOR: 'MODERATOR',
@ -114,74 +131,94 @@ export default {
},
methods: {
combineResubmissionDateAndTime() {
// getTimezoneOffset
const formattedDate = new Date(this.resubmissionDate)
const [hours, minutes] = this.resubmissionTime.split(':')
formattedDate.setHours(parseInt(hours))
formattedDate.setMinutes(parseInt(minutes))
return formattedDate
},
onSubmit(mType) {
utcResubmissionDateTime() {
if (!this.resubmissionDate) return null
const localResubmissionDateAndTime = this.combineResubmissionDateAndTime()
return new Date(
localResubmissionDateAndTime.getTime() +
localResubmissionDateAndTime.getTimezoneOffset() * 60000,
)
},
onSubmit() {
this.loading = true
if (this.chatOrMemo === 0) {
this.$apollo
.mutate({
mutation: adminCreateContributionMessage,
variables: {
contributionId: this.contributionId,
message: this.form.text,
messageType: mType,
resubmissionAt: this.showResubmissionDate
? this.combineResubmissionDateAndTime().toString()
: null,
},
})
.then((result) => {
if (
this.hideResubmission &&
let mutation
let updateOnlyResubmissionAt = false
const resubmissionAtDate = this.showResubmissionDate
? this.combineResubmissionDateAndTime()
: null
const variables = {
resubmissionAt: resubmissionAtDate ? resubmissionAtDate.toString() : null,
}
// update only resubmission date?
if (this.form.text === '' && this.form.memo === this.contributionMemo) {
mutation = adminUpdateContribution
variables.id = this.contributionId
updateOnlyResubmissionAt = true
}
// update tabindex 0 = dialog or 1 = moderator
else if (this.tabindex !== 2) {
mutation = adminCreateContributionMessage
variables.message = this.form.text
variables.messageType =
this.tabindex === 0 ? this.messageType.DIALOG : this.messageType.MODERATOR
variables.contributionId = this.contributionId
// update contribution memo, tabindex 2
} else {
mutation = adminUpdateContribution
variables.memo = this.form.memo
variables.id = this.contributionId
}
if (this.showResubmissionDate && resubmissionAtDate < new Date()) {
this.toastError(this.$t('contributionMessagesForm.resubmissionDateInPast'))
this.loading = false
return
}
this.$apollo
.mutate({ mutation, variables })
.then((result) => {
if (
(this.hideResubmission &&
this.showResubmissionDate &&
this.combineResubmissionDateAndTime() > new Date()
) {
this.$emit('update-contributions')
} else {
this.$emit('get-list-contribution-messages', this.contributionId)
resubmissionAtDate > new Date()) ||
this.tabindex === 2
) {
this.$emit('update-contributions')
} else {
this.$emit('get-list-contribution-messages', this.contributionId)
// update status increase message count and update chat symbol
// if (updateOnlyResubmissionAt === true) no message was created
if (!updateOnlyResubmissionAt) {
this.$emit('update-status', this.contributionId)
}
this.onReset()
this.toastSuccess(this.$t('message.request'))
this.loading = false
})
.catch((error) => {
this.toastError(error.message)
this.loading = false
})
} else {
this.$apollo
.mutate({
mutation: adminUpdateContribution,
variables: {
id: this.contributionId,
memo: this.form.memo,
},
})
.then((result) => {
this.$emit('get-list-contribution-messages', this.contributionId)
this.$emit('update-status', this.contributionId)
this.$emit('reload-contribution', this.contributionId)
this.toastSuccess(this.$t('message.request'))
this.loading = false
})
.catch((error) => {
this.toastError(error.message)
this.loading = false
})
}
}
this.toastSuccess(this.$t('message.request'))
this.loading = false
})
.catch((error) => {
this.toastError(error.message)
this.loading = false
})
},
onReset(event) {
this.form.text = ''
this.form.memo = this.contributionMemo
this.showResubmissionDate = false
this.resubmissionDate = null
this.resubmissionTime = '00:00'
this.resubmissionDate = this.inputResubmissionDate
this.resubmissionTime = this.inputResubmissionDate
? new Date(this.inputResubmissionDate).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})
: '00:00'
this.showResubmissionDate =
this.inputResubmissionDate !== undefined && this.inputResubmissionDate !== null
},
enableMemo() {
this.chatOrMemo = 1
@ -199,6 +236,9 @@ export default {
moderatorDisabled() {
return this.form.text === '' || this.loading || this.chatOrMemo === 1
},
now() {
return new Date()
},
},
}
</script>

View File

@ -13,6 +13,7 @@
:contributionId="contributionId"
:contributionMemo="contributionMemo"
:hideResubmission="hideResubmission"
:inputResubmissionDate="resubmissionAt"
@get-list-contribution-messages="$apollo.queries.Messages.refetch()"
@update-status="updateStatus"
@reload-contribution="reloadContribution"
@ -53,6 +54,10 @@ export default {
type: Boolean,
required: true,
},
resubmissionAt: {
type: String,
required: false,
},
},
data() {
return {

View File

@ -112,6 +112,7 @@
:contributionStatus="row.item.status"
:contributionUserId="row.item.userId"
:contributionMemo="row.item.memo"
:resubmissionAt="row.item.resubmissionAt"
:hideResubmission="hideResubmission"
@update-status="updateStatus"
@reload-contribution="reloadContribution"
@ -160,6 +161,10 @@ export default {
type: Boolean,
required: true,
},
resubmissionAt: {
type: Date,
required: false,
},
},
methods: {
myself(item) {

View File

@ -42,6 +42,7 @@ export const adminListContributions = gql`
deletedBy
moderatorId
userId
resubmissionAt
}
}
}

View File

@ -1,8 +1,20 @@
import gql from 'graphql-tag'
export const adminUpdateContribution = gql`
mutation ($id: Int!, $amount: Decimal, $memo: String, $creationDate: String) {
adminUpdateContribution(id: $id, amount: $amount, memo: $memo, creationDate: $creationDate) {
mutation (
$id: Int!
$amount: Decimal
$memo: String
$creationDate: String
$resubmissionAt: String
) {
adminUpdateContribution(
id: $id
amount: $amount
memo: $memo
creationDate: $creationDate
resubmissionAt: $resubmissionAt
) {
amount
date
memo

View File

@ -22,6 +22,7 @@ export const getContribution = gql`
deletedBy
moderatorId
userId
resubmissionAt
}
}
`

View File

@ -32,6 +32,9 @@
"validFrom": "Startdatum",
"validTo": "Enddatum"
},
"contributionMessagesForm": {
"resubmissionDateInPast": "Wiedervorlage Datum befindet sich in der Vergangenheit!"
},
"contributions": {
"all": "Alle",
"confirms": "Bestätigt",
@ -86,8 +89,7 @@
"short_hash": "({shortHash})"
},
"form": {
"cancel": "Abbrechen",
"submit": "Senden"
"cancel": "Abbrechen"
},
"help": {
"help": "Hilfe",
@ -111,14 +113,16 @@
"request": "Die Anfrage wurde gesendet."
},
"moderator": {
"chat": "Chat",
"history": "Die Daten wurden geändert. Dies sind die alten Daten.",
"show-submission-form": "warten auf Erinnerung?",
"moderator": "Moderator",
"notice": "Moderator Notiz",
"memo": "Memo",
"memo-modify": "Memo bearbeiten",
"memo-modified": "Memo vom Moderator bearbeitet",
"notice": "Notiz",
"notice-tooltip": "Die Notiz ist nur für Moderatoren sichtbar",
"memo": "Text ändern",
"memo-tooltip": "Den Beitragstext bearbeiten",
"memo-modified": "Text vom Moderator bearbeitet.",
"message": "Nachricht",
"message-tooltip": "Nachricht an Benutzer schreiben",
"request": "Diese Nachricht ist nur für die Moderatoren sichtbar!"
},
"name": "Name",

View File

@ -32,6 +32,9 @@
"validFrom": "Start-date",
"validTo": "End-Date"
},
"contributionMessagesForm": {
"resubmissionDateInPast": "Resubmission date is in the past!"
},
"contributions": {
"all": "All",
"confirms": "Confirmed",
@ -86,8 +89,7 @@
"short_hash": "({shortHash})"
},
"form": {
"cancel": "Cancel",
"submit": "Send"
"cancel": "Cancel"
},
"help": {
"help": "Help",
@ -111,14 +113,16 @@
"request": "Request has been sent."
},
"moderator": {
"chat": "Chat",
"history": "The data has been changed. This is the old data.",
"show-submission-form": "wait for reminder?",
"moderator": "Moderator",
"notice": "Moderator note",
"memo": "Memo",
"memo-modify": "Modify Memo",
"memo-modified": "Memo edited by moderator",
"notice": "Note",
"notice-tooltip": "The note is only visible to moderators",
"memo": "Edit text",
"memo-tooltip": "Edit the text of the contribution",
"memo-modified": "Text edited by moderator",
"message": "Message",
"message-tooltip": "Write message to user",
"request": "This message is only visible to the moderators!"
},
"name": "Name",

View File

@ -12,7 +12,7 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0077-add_resubmission_date_contribution_message',
DB_VERSION: '0078-move_resubmission_date',
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

View File

@ -63,6 +63,24 @@ export class ContributionMessageBuilder {
return this
}
/**
* set contribution message type dialog and copy message
* @param contribution
* @param message
* @returns
*/
public setDialogType(message: string): this {
this.contributionMessage.message = message
this.contributionMessage.type = ContributionMessageType.DIALOG
return this
}
public setMessageAndType(message: string, type: ContributionMessageType): this {
this.contributionMessage.message = message
this.contributionMessage.type = type
return this
}
public setUser(user: User): this {
this.contributionMessage.user = user
this.contributionMessage.userId = user.id

View File

@ -24,4 +24,8 @@ export class AdminUpdateContributionArgs {
@Field(() => String, { nullable: true })
@isValidDateString()
creationDate?: string | null
@Field(() => String, { nullable: true })
@isValidDateString()
resubmissionAt?: string | null
}

View File

@ -25,6 +25,7 @@ export class Contribution {
this.updatedBy = contribution.updatedBy
this.moderatorId = contribution.moderatorId
this.userId = contribution.userId
this.resubmissionAt = contribution.resubmissionAt
}
@Field(() => Int)
@ -83,6 +84,9 @@ export class Contribution {
@Field(() => Int, { nullable: true })
userId: number | null
@Field(() => Date, { nullable: true })
resubmissionAt: Date | null
}
@ObjectType()

View File

@ -24,6 +24,7 @@ import {
} from '@/seeds/graphql/mutations'
import { listContributionMessages, adminListContributionMessages } from '@/seeds/graphql/queries'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
import { peterLustig } from '@/seeds/users/peter-lustig'
jest.mock('@/emails/sendEmailVariants', () => {
@ -78,6 +79,7 @@ describe('ContributionMessageResolver', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
await userFactory(testEnv, bobBaumeister)
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
@ -129,7 +131,7 @@ describe('ContributionMessageResolver', () => {
)
})
it('throws error when contribution.userId equals user.id', async () => {
it('treat the logged-in user as a normal user, not as a moderator or admin if contribution.userId equals user.id', async () => {
jest.clearAllMocks()
await mutate({
mutation: login,
@ -143,29 +145,30 @@ describe('ContributionMessageResolver', () => {
creationDate: new Date().toString(),
},
})
await expect(
mutate({
mutation: adminCreateContributionMessage,
variables: {
contributionId: result2.data.createContribution.id,
message: 'Test',
const mutationResult = await mutate({
mutation: adminCreateContributionMessage,
variables: {
contributionId: result2.data.createContribution.id,
message: 'Test',
},
})
expect(logger.debug).toBeCalledTimes(4)
expect(logger.debug).toHaveBeenNthCalledWith(
4,
'use UnconfirmedContributionUserAddMessageRole',
)
expect(mutationResult).toEqual(
expect.objectContaining({
data: {
adminCreateContributionMessage: expect.objectContaining({
id: expect.any(Number),
message: 'Test',
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
}),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
'ContributionMessage was not sent successfully: Error: Admin can not answer on his own contribution',
),
],
}),
)
})
it('logs the error "ContributionMessage was not sent successfully: Error: Admin can not answer on his own contribution"', () => {
expect(logger.error).toBeCalledWith(
'ContributionMessage was not sent successfully: Error: Admin can not answer on his own contribution',
new Error('Admin can not answer on his own contribution'),
)
})
})
@ -332,35 +335,84 @@ describe('ContributionMessageResolver', () => {
)
})
it('throws error when other user tries to send createContributionMessage', async () => {
it('other user tries to send createContributionMessage but is also moderator or admin so it is allowed', async () => {
jest.clearAllMocks()
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await expect(
mutate({
mutation: createContributionMessage,
variables: {
contributionId: result.data.createContribution.id,
message: 'Test',
const mutationResult = await mutate({
mutation: createContributionMessage,
variables: {
contributionId: result.data.createContribution.id,
message: 'Test',
},
})
expect(logger.debug).toBeCalledTimes(4)
expect(logger.debug).toHaveBeenNthCalledWith(
4,
'use UnconfirmedContributionAdminAddMessageRole',
)
expect(mutationResult).toEqual(
expect.objectContaining({
data: {
createContributionMessage: expect.objectContaining({
id: expect.any(Number),
message: 'Test',
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
}),
},
}),
).resolves.toEqual(
)
})
it('throws error when other user tries to send createContributionMessage', async () => {
jest.clearAllMocks()
await mutate({
mutation: login,
variables: { email: 'bob@baumeister.de', password: 'Aa12345_' },
})
const mutationResult = await mutate({
mutation: createContributionMessage,
variables: {
contributionId: result.data.createContribution.id,
message: 'Test',
},
})
expect(logger.debug).toBeCalledTimes(4)
expect(logger.debug).toHaveBeenNthCalledWith(
4,
'use UnconfirmedContributionAdminAddMessageRole',
)
expect(mutationResult).toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
'ContributionMessage was not sent successfully: Error: Can not send message to contribution of another user',
'ContributionMessage was not sent successfully: Error: missing right ADMIN_CREATE_CONTRIBUTION_MESSAGE for user',
),
],
}),
)
})
it('logs the error "ContributionMessage was not sent successfully: Error: Can not send message to contribution of another user"', () => {
expect(logger.error).toBeCalledWith(
'ContributionMessage was not sent successfully: Error: Can not send message to contribution of another user',
new Error('Can not send message to contribution of another user'),
it('logs the error "ContributionMessage was not sent successfully: Error: missing right ADMIN_CREATE_CONTRIBUTION_MESSAGE for user"', () => {
expect(logger.debug).toBeCalledTimes(4)
expect(logger.error).toHaveBeenNthCalledWith(
1,
'missing right ADMIN_CREATE_CONTRIBUTION_MESSAGE for user',
expect.any(Number),
)
expect(logger.error).toHaveBeenNthCalledWith(
2,
'ContributionMessage was not sent successfully: Error: missing right ADMIN_CREATE_CONTRIBUTION_MESSAGE for user',
new Error('missing right ADMIN_CREATE_CONTRIBUTION_MESSAGE for user'),
)
})
})
@ -450,13 +502,22 @@ describe('ContributionMessageResolver', () => {
expect.objectContaining({
data: {
listContributionMessages: {
count: 2,
count: 3,
messages: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
message: 'Admin Test',
type: 'DIALOG',
userFirstName: 'Peter',
userId: expect.any(Number),
userLastName: 'Lustig',
}),
expect.objectContaining({
id: expect.any(Number),
message: 'Test',
type: 'DIALOG',
userFirstName: 'Peter',
userId: expect.any(Number),
userLastName: 'Lustig',
}),
expect.objectContaining({
@ -464,6 +525,7 @@ describe('ContributionMessageResolver', () => {
message: 'User Test',
type: 'DIALOG',
userFirstName: 'Bibi',
userId: expect.any(Number),
userLastName: 'Bloxberg',
}),
]),
@ -535,13 +597,22 @@ describe('ContributionMessageResolver', () => {
expect.objectContaining({
data: {
adminListContributionMessages: {
count: 3,
count: 4,
messages: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
message: 'Admin Test',
type: 'DIALOG',
userFirstName: 'Peter',
userId: expect.any(Number),
userLastName: 'Lustig',
}),
expect.objectContaining({
id: expect.any(Number),
message: 'Test',
type: 'DIALOG',
userFirstName: 'Peter',
userId: expect.any(Number),
userLastName: 'Lustig',
}),
expect.objectContaining({
@ -549,6 +620,7 @@ describe('ContributionMessageResolver', () => {
message: 'User Test',
type: 'DIALOG',
userFirstName: 'Bibi',
userId: expect.any(Number),
userLastName: 'Bloxberg',
}),
expect.objectContaining({
@ -556,6 +628,7 @@ describe('ContributionMessageResolver', () => {
message: 'Internal moderator communication',
type: 'MODERATOR',
userFirstName: 'Peter',
userId: expect.any(Number),
userLastName: 'Lustig',
}),
]),

View File

@ -1,15 +1,13 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import { getConnection } from '@dbTools/typeorm'
import { EntityManager, FindOptionsRelations, getConnection } from '@dbTools/typeorm'
import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage'
import { User as DbUser } from '@entity/User'
import { UserContact as DbUserContact } from '@entity/UserContact'
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
import { ContributionMessageArgs } from '@arg/ContributionMessageArgs'
import { Paginated } from '@arg/Paginated'
import { ContributionMessageType } from '@enum/ContributionMessageType'
import { ContributionStatus } from '@enum/ContributionStatus'
import { Order } from '@enum/Order'
import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage'
@ -19,8 +17,10 @@ import {
EVENT_ADMIN_CONTRIBUTION_MESSAGE_CREATE,
EVENT_CONTRIBUTION_MESSAGE_CREATE,
} from '@/event/Events'
import { UpdateUnconfirmedContributionContext } from '@/interactions/updateUnconfirmedContribution/UpdateUnconfirmedContribution.context'
import { Context, getUser } from '@/server/context'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { findContributionMessages } from './util/findContributionMessages'
@ -29,52 +29,54 @@ export class ContributionMessageResolver {
@Authorized([RIGHTS.CREATE_CONTRIBUTION_MESSAGE])
@Mutation(() => ContributionMessage)
async createContributionMessage(
@Args() { contributionId, message }: ContributionMessageArgs,
@Args() contributionMessageArgs: ContributionMessageArgs,
@Ctx() context: Context,
): Promise<ContributionMessage> {
const user = getUser(context)
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('REPEATABLE READ')
const contributionMessage = DbContributionMessage.create()
const { contributionId } = contributionMessageArgs
const updateUnconfirmedContributionContext = new UpdateUnconfirmedContributionContext(
contributionId,
contributionMessageArgs,
context,
)
let finalContribution: DbContribution | undefined
let finalContributionMessage: DbContributionMessage | undefined
try {
const contribution = await DbContribution.findOne({ where: { id: contributionId } })
if (!contribution) {
throw new LogError('Contribution not found', contributionId)
}
if (contribution.userId !== user.id) {
throw new LogError(
'Can not send message to contribution of another user',
contribution.userId,
user.id,
)
}
await getConnection().transaction(
'REPEATABLE READ',
async (transactionalEntityManager: EntityManager) => {
const { contribution, contributionMessage, contributionChanged } =
await updateUnconfirmedContributionContext.run(transactionalEntityManager)
contributionMessage.contributionId = contributionId
contributionMessage.createdAt = new Date()
contributionMessage.message = message
contributionMessage.userId = user.id
contributionMessage.type = ContributionMessageType.DIALOG
contributionMessage.isModerator = false
await queryRunner.manager.insert(DbContributionMessage, contributionMessage)
if (contributionChanged) {
await transactionalEntityManager.update(
DbContribution,
{ id: contributionId },
contribution,
)
}
if (contributionMessage) {
await transactionalEntityManager.insert(DbContributionMessage, contributionMessage)
}
if (contribution.contributionStatus === ContributionStatus.IN_PROGRESS) {
contribution.contributionStatus = ContributionStatus.PENDING
await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution)
}
await queryRunner.commitTransaction()
await EVENT_CONTRIBUTION_MESSAGE_CREATE(
user,
{ id: contributionMessage.contributionId } as DbContribution,
contributionMessage,
finalContribution = contribution
finalContributionMessage = contributionMessage
},
)
} catch (e) {
await queryRunner.rollbackTransaction()
throw new LogError(`ContributionMessage was not sent successfully: ${e}`, e)
} finally {
await queryRunner.release()
}
return new ContributionMessage(contributionMessage, user)
if (!finalContribution || !finalContributionMessage) {
throw new LogError('ContributionMessage was not sent successfully')
}
const user = getUser(context)
await EVENT_CONTRIBUTION_MESSAGE_CREATE(
user,
{ id: contributionId } as DbContribution,
finalContributionMessage,
)
return new ContributionMessage(finalContributionMessage, user)
}
@Authorized([RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES])
@ -125,77 +127,73 @@ export class ContributionMessageResolver {
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE])
@Mutation(() => ContributionMessage)
async adminCreateContributionMessage(
@Args() { contributionId, message, messageType, resubmissionAt }: ContributionMessageArgs,
@Args() contributionMessageArgs: ContributionMessageArgs,
@Ctx() context: Context,
): Promise<ContributionMessage> {
const moderator = getUser(context)
const { contributionId, messageType } = contributionMessageArgs
const updateUnconfirmedContributionContext = new UpdateUnconfirmedContributionContext(
contributionId,
contributionMessageArgs,
context,
)
const relations: FindOptionsRelations<DbContribution> =
messageType === ContributionMessageType.DIALOG
? { user: { emailContact: true } }
: { user: true }
let finalContribution: DbContribution | undefined
let finalContributionMessage: DbContributionMessage | undefined
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) {
throw new LogError('Contribution not found', contributionId)
}
if (contribution.userId === moderator.id) {
throw new LogError('Admin can not answer on his own contribution', contributionId)
}
if (!contribution.user.emailContact && contribution.user.emailId) {
contribution.user.emailContact = await DbUserContact.findOneOrFail({
where: { id: contribution.user.emailId },
})
}
contributionMessage.contributionId = contributionId
contributionMessage.createdAt = new Date()
contributionMessage.message = message
contributionMessage.userId = moderator.id
contributionMessage.type = messageType
contributionMessage.isModerator = true
if (resubmissionAt) {
contributionMessage.resubmissionAt = new Date(resubmissionAt)
}
await queryRunner.manager.insert(DbContributionMessage, contributionMessage)
if (messageType !== ContributionMessageType.MODERATOR) {
// change status (does not apply to moderator messages)
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)
}
// send email (never for moderator messages)
void sendAddedContributionMessageEmail({
firstName: contribution.user.firstName,
lastName: contribution.user.lastName,
email: contribution.user.emailContact.email,
language: contribution.user.language,
senderFirstName: moderator.firstName,
senderLastName: moderator.lastName,
contributionMemo: contribution.memo,
})
}
await queryRunner.commitTransaction()
await EVENT_ADMIN_CONTRIBUTION_MESSAGE_CREATE(
{ id: contribution.userId } as DbUser,
moderator,
contribution,
contributionMessage,
await getConnection().transaction(
'REPEATABLE READ',
async (transactionalEntityManager: EntityManager) => {
const { contribution, contributionMessage, contributionChanged } =
await updateUnconfirmedContributionContext.run(transactionalEntityManager, relations)
if (contributionChanged) {
await transactionalEntityManager.update(
DbContribution,
{ id: contributionId },
contribution,
)
logger.debug(
'contribution changed, resubmission at: %s, status: %s',
contribution.resubmissionAt,
contribution.contributionStatus,
)
}
if (contributionMessage) {
await transactionalEntityManager.insert(DbContributionMessage, contributionMessage)
}
finalContribution = contribution
finalContributionMessage = contributionMessage
},
)
} catch (e) {
await queryRunner.rollbackTransaction()
throw new LogError(`ContributionMessage was not sent successfully: ${e}`, e)
} finally {
await queryRunner.release()
}
return new ContributionMessage(contributionMessage, moderator)
if (!finalContribution || !finalContributionMessage) {
throw new LogError('ContributionMessage was not sent successfully')
}
const moderator = getUser(context)
if (messageType === ContributionMessageType.DIALOG) {
// send email (never for moderator messages)
void sendAddedContributionMessageEmail({
firstName: finalContribution.user.firstName,
lastName: finalContribution.user.lastName,
email: finalContribution.user.emailContact.email,
language: finalContribution.user.language,
senderFirstName: moderator.firstName,
senderLastName: moderator.lastName,
contributionMemo: finalContribution.memo,
})
}
await EVENT_ADMIN_CONTRIBUTION_MESSAGE_CREATE(
{ id: finalContribution.userId } as DbUser,
moderator,
finalContribution,
finalContributionMessage,
)
return new ContributionMessage(finalContributionMessage, moderator)
}
}

View File

@ -187,10 +187,10 @@ export class ContributionResolver {
const { contribution, contributionMessage, availableCreationSums } =
await updateUnconfirmedContributionContext.run()
await getConnection().transaction(async (transactionalEntityManager: EntityManager) => {
await Promise.all([
transactionalEntityManager.save(contribution),
transactionalEntityManager.save(contributionMessage),
])
await transactionalEntityManager.save(contribution)
if (contributionMessage) {
await transactionalEntityManager.save(contributionMessage)
}
})
const user = getUser(context)
await EVENT_CONTRIBUTION_UPDATE(user, contribution, contributionArgs.amount)
@ -263,16 +263,28 @@ export class ContributionResolver {
const { contribution, contributionMessage, createdByUserChangedByModerator } =
await updateUnconfirmedContributionContext.run()
await getConnection().transaction(async (transactionalEntityManager: EntityManager) => {
await Promise.all([
transactionalEntityManager.save(contribution),
transactionalEntityManager.save(contributionMessage),
])
await transactionalEntityManager.save(contribution)
// TODO: move into specialized view or formatting for logging class
logger.debug('saved changed contribution', {
id: contribution.id,
amount: contribution.amount.toString(),
memo: contribution.memo,
contributionDate: contribution.contributionDate.toString(),
resubmissionAt: contribution.resubmissionAt?.toString(),
status: contribution.contributionStatus.toString(),
})
if (contributionMessage) {
await transactionalEntityManager.save(contributionMessage)
// TODO: move into specialized view or formatting for logging class
logger.debug('save new contributionMessage', {
contributionId: contributionMessage.contributionId,
type: contributionMessage.type,
message: contributionMessage.message,
isModerator: contributionMessage.isModerator,
})
}
})
const moderator = getUser(context)
const user = await DbUser.findOneOrFail({
where: { id: contribution.userId },
relations: ['emailContact'],
})
const result = new AdminUpdateContribution()
result.amount = contribution.amount
@ -286,6 +298,11 @@ export class ContributionResolver {
contribution.amount,
)
if (createdByUserChangedByModerator && adminUpdateContributionArgs.memo) {
const user = await DbUser.findOneOrFail({
where: { id: contribution.userId },
relations: ['emailContact'],
})
void sendContributionChangedByModeratorEmail({
firstName: user.firstName,
lastName: user.lastName,

View File

@ -1,7 +1,14 @@
/* eslint-disable security/detect-object-injection */
import { Brackets, In, Like, Not, SelectQueryBuilder } from '@dbTools/typeorm'
import {
Brackets,
In,
IsNull,
LessThanOrEqual,
Like,
Not,
SelectQueryBuilder,
} from '@dbTools/typeorm'
import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionMessage } from '@entity/ContributionMessage'
import { Paginated } from '@arg/Paginated'
import { SearchContributionsFilterArgs } from '@arg/SearchContributionsFilterArgs'
@ -47,29 +54,12 @@ export const findContributions = async (
...(filter.noHashtag && { memo: Not(Like(`%#%`)) }),
})
if (filter.hideResubmission) {
queryBuilder
.leftJoinAndSelect(
(qb: SelectQueryBuilder<ContributionMessage>) => {
return qb
.select('resubmission_at', 'resubmissionAt')
.addSelect('id', 'latestMessageId')
.addSelect('contribution_id', 'latestMessageContributionId')
.addSelect(
'ROW_NUMBER() OVER (PARTITION BY latestMessageContributionId ORDER BY created_at DESC)',
'rn',
)
.from(ContributionMessage, 'contributionMessage')
},
'latestContributionMessage',
'latestContributionMessage.latestMessageContributionId = Contribution.id AND latestContributionMessage.rn = 1',
)
.andWhere(
new Brackets((qb) => {
qb.where('latestContributionMessage.resubmissionAt IS NULL').orWhere(
'latestContributionMessage.resubmissionAt <= NOW()',
)
}),
)
const now = new Date(new Date().toUTCString())
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where({ resubmissionAt: IsNull() }).orWhere({ resubmissionAt: LessThanOrEqual(now) })
}),
)
}
queryBuilder.printSql()
if (filter.query) {

View File

@ -4,11 +4,15 @@ import { Decimal } from 'decimal.js-light'
import { Role } from '@/auth/Role'
import { ContributionLogic } from '@/data/Contribution.logic'
import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder'
import { ContributionStatus } from '@/graphql/enum/ContributionStatus'
import { Context, getClientTimezoneOffset } from '@/server/context'
import { LogError } from '@/server/LogError'
export abstract class AbstractUnconfirmedContributionRole {
private availableCreationSums?: Decimal[]
protected changed = true
private currentStep = 0
public constructor(
protected self: Contribution,
@ -20,6 +24,10 @@ export abstract class AbstractUnconfirmedContributionRole {
}
}
public isChanged(): boolean {
return this.changed
}
// steps which return void throw on each error
// first, check if it can be updated
protected abstract checkAuthorization(user: User, role: Role): void
@ -30,6 +38,10 @@ export abstract class AbstractUnconfirmedContributionRole {
throw new LogError('Month of contribution can not be changed')
}
if (this.self.contributionStatus === ContributionStatus.CONFIRMED) {
throw new LogError('the contribution is already confirmed, cannot be changed anymore')
}
const contributionLogic = new ContributionLogic(this.self)
this.availableCreationSums = await contributionLogic.getAvailableCreationSums(
clientTimezoneOffset,
@ -45,14 +57,31 @@ export abstract class AbstractUnconfirmedContributionRole {
// third, actually update entity
protected abstract update(): void
protected wasUpdateAlreadyCalled(): boolean {
return this.currentStep > 3
}
// call all steps in order
public async checkAndUpdate(context: Context): Promise<void> {
if (!context.user || !context.role) {
throw new LogError('missing user or role on context')
}
this.currentStep = 1
this.checkAuthorization(context.user, context.role)
this.currentStep = 2
await this.validate(getClientTimezoneOffset(context))
this.currentStep = 3
this.update()
this.currentStep = 4
}
public createContributionMessage(): ContributionMessageBuilder | undefined {
// must be called before call at update
if (this.wasUpdateAlreadyCalled()) {
throw new LogError('please call before call of checkAndUpdate')
}
const contributionMessageBuilder = new ContributionMessageBuilder()
return contributionMessageBuilder.setParentContribution(this.self).setHistoryType(this.self)
}
public getAvailableCreationSums(): Decimal[] {

View File

@ -3,12 +3,18 @@ import { User } from '@entity/User'
import { RIGHTS } from '@/auth/RIGHTS'
import { Role } from '@/auth/Role'
import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder'
import { AdminUpdateContributionArgs } from '@/graphql/arg/AdminUpdateContributionArgs'
import { ContributionStatus } from '@/graphql/enum/ContributionStatus'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { AbstractUnconfirmedContributionRole } from './AbstractUnconfirmedContribution.role'
/**
* This role will be used for Moderators and Admins which want to edit a contribution
* Admins and Moderators are currently not allowed to edit her own contributions with the admin/moderator role
*/
export class UnconfirmedContributionAdminRole extends AbstractUnconfirmedContributionRole {
public constructor(
contribution: Contribution,
@ -20,15 +26,39 @@ export class UnconfirmedContributionAdminRole extends AbstractUnconfirmedContrib
updateData.amount ?? contribution.amount,
updateData.creationDate ? new Date(updateData.creationDate) : contribution.contributionDate,
)
logger.debug('use UnconfirmedContributionAdminRole')
}
/**
*
* @returns true if memo, amount or creation date are changed
*/
private isContributionChanging(): boolean {
if (this.wasUpdateAlreadyCalled()) {
throw new LogError('please call only before calling checkAndUpdate')
}
return (
(this.updateData.memo && this.self.memo !== this.updateData.memo) ||
(this.updatedAmount && this.self.amount !== this.updatedAmount) ||
+this.self.contributionDate !== +this.updatedCreationDate
)
}
protected update(): void {
if (this.isContributionChanging()) {
// set update fields only if actual contribution was changed, not only the status or resubmission date
this.self.updatedAt = new Date()
this.self.updatedBy = this.moderator.id
this.self.contributionStatus = ContributionStatus.PENDING
}
this.self.amount = this.updatedAmount
this.self.memo = this.updateData.memo ?? this.self.memo
this.self.contributionDate = this.updatedCreationDate
this.self.contributionStatus = ContributionStatus.PENDING
this.self.updatedAt = new Date()
this.self.updatedBy = this.moderator.id
if (this.updateData.resubmissionAt) {
this.self.resubmissionAt = new Date(this.updateData.resubmissionAt)
} else {
this.self.resubmissionAt = null
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -37,20 +67,41 @@ export class UnconfirmedContributionAdminRole extends AbstractUnconfirmedContrib
!role.hasRight(RIGHTS.MODERATOR_UPDATE_CONTRIBUTION_MEMO) &&
this.self.moderatorId === null
) {
throw new LogError('An admin is not allowed to update an user contribution')
throw new LogError("The Moderator hasn't the right MODERATOR_UPDATE_CONTRIBUTION_MEMO")
}
return this
}
protected async validate(clientTimezoneOffset: number): Promise<void> {
await super.validate(clientTimezoneOffset)
// creation date is currently not changeable
const newResubmissionDate = this.updateData.resubmissionAt
? new Date(this.updateData.resubmissionAt)
: null
const resubmissionNotChanged =
this.self.resubmissionAt !== null &&
newResubmissionDate !== null &&
+this.self.resubmissionAt === +newResubmissionDate
// check if at least one value of contribution was changed and if not, throw an exception
// frontend and admin frontend should only call with at least some changes in the args
if (
this.self.memo === this.updateData.memo &&
this.self.amount === this.updatedAmount &&
this.self.contributionDate.getTime() === new Date(this.updatedCreationDate).getTime()
!this.isContributionChanging() &&
((this.self.resubmissionAt === null && newResubmissionDate === null) ||
resubmissionNotChanged)
) {
throw new LogError("the contribution wasn't changed at all")
}
}
public createContributionMessage(): ContributionMessageBuilder | undefined {
if (!this.isContributionChanging()) {
return
}
const builder = super.createContributionMessage()
if (builder) {
return builder.setIsModerator(true)
}
}
}

View File

@ -0,0 +1,75 @@
import { Contribution } from '@entity/Contribution'
import { User } from '@entity/User'
import { RIGHTS } from '@/auth/RIGHTS'
import { Role } from '@/auth/Role'
import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder'
import { ContributionMessageArgs } from '@/graphql/arg/ContributionMessageArgs'
import { ContributionMessageType } from '@/graphql/enum/ContributionMessageType'
import { ContributionStatus } from '@/graphql/enum/ContributionStatus'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { AbstractUnconfirmedContributionRole } from './AbstractUnconfirmedContribution.role'
/**
* This role will be used for Moderators and Admins which want to comment a contribution
* Admins and Moderators are currently not allowed to comment her own contributions with the admin/moderator role
*/
export class UnconfirmedContributionAdminAddMessageRole extends AbstractUnconfirmedContributionRole {
public constructor(contribution: Contribution, private updateData: ContributionMessageArgs) {
super(contribution, contribution.amount, contribution.contributionDate)
logger.debug('use UnconfirmedContributionAdminAddMessageRole')
}
protected update(): void {
let newStatus = this.self.contributionStatus
// change status (does not apply to moderator messages)
if (this.updateData.messageType !== ContributionMessageType.MODERATOR) {
newStatus = ContributionStatus.IN_PROGRESS
}
const resubmissionDate: Date | null = this.updateData.resubmissionAt
? new Date(this.updateData.resubmissionAt)
: null
if (
this.self.contributionStatus !== newStatus ||
this.self.resubmissionAt !== resubmissionDate
) {
this.self.contributionStatus = newStatus
this.self.resubmissionAt = resubmissionDate
} else {
this.changed = false
}
}
protected checkAuthorization(user: User, role: Role): AbstractUnconfirmedContributionRole {
if (!role.hasRight(RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE)) {
throw new LogError('missing right ADMIN_CREATE_CONTRIBUTION_MESSAGE for user', user.id)
}
// TODO: think if there are cases in which admin comment his own contribution
if (
this.self.userId === user.id &&
this.updateData.messageType === ContributionMessageType.MODERATOR
) {
throw new LogError(
'Moderator|Admin can not make a moderator comment on his own contribution',
this.self.id,
)
}
return this
}
protected async validate(clientTimezoneOffset: number): Promise<void> {
await super.validate(clientTimezoneOffset)
}
public createContributionMessage(): ContributionMessageBuilder | undefined {
const builder = super.createContributionMessage()
if (builder) {
return builder
.setIsModerator(true)
.setMessageAndType(this.updateData.message, this.updateData.messageType)
}
}
}

View File

@ -1,15 +1,22 @@
import { Contribution } from '@entity/Contribution'
import { User } from '@entity/User'
import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder'
import { ContributionArgs } from '@/graphql/arg/ContributionArgs'
import { ContributionStatus } from '@/graphql/enum/ContributionStatus'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { AbstractUnconfirmedContributionRole } from './AbstractUnconfirmedContribution.role'
/**
* This role will be used for Users which want to edit there own contribution,
* independent from there role, because the own contribution can only be edited in user role
*/
export class UnconfirmedContributionUserRole extends AbstractUnconfirmedContributionRole {
public constructor(contribution: Contribution, private updateData: ContributionArgs) {
super(contribution, updateData.amount, new Date(updateData.creationDate))
logger.debug('use UnconfirmedContributionUserRole')
}
protected update(): void {
@ -20,6 +27,7 @@ export class UnconfirmedContributionUserRole extends AbstractUnconfirmedContribu
this.self.updatedAt = new Date()
// null because updated by user them self
this.self.updatedBy = null
this.self.resubmissionAt = null
}
protected checkAuthorization(user: User): AbstractUnconfirmedContributionRole {
@ -55,4 +63,11 @@ export class UnconfirmedContributionUserRole extends AbstractUnconfirmedContribu
throw new LogError("the contribution wasn't changed at all")
}
}
public createContributionMessage(): ContributionMessageBuilder | undefined {
const builder = super.createContributionMessage()
if (builder) {
return builder.setIsModerator(false)
}
}
}

View File

@ -0,0 +1,62 @@
import { Contribution } from '@entity/Contribution'
import { User } from '@entity/User'
import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder'
import { ContributionMessageArgs } from '@/graphql/arg/ContributionMessageArgs'
import { ContributionMessageType } from '@/graphql/enum/ContributionMessageType'
import { ContributionStatus } from '@/graphql/enum/ContributionStatus'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { AbstractUnconfirmedContributionRole } from './AbstractUnconfirmedContribution.role'
/**
* This role will be used if a User comment his contribution, for example as answer to a moderator question
* independent from there role, because the own contribution can only be commented in user role
*/
export class UnconfirmedContributionUserAddMessageRole extends AbstractUnconfirmedContributionRole {
public constructor(contribution: Contribution, private updateData: ContributionMessageArgs) {
super(contribution, contribution.amount, contribution.contributionDate)
logger.debug('use UnconfirmedContributionUserAddMessageRole')
}
protected update(): void {
if (
this.self.contributionStatus === ContributionStatus.IN_PROGRESS ||
this.self.resubmissionAt !== null
) {
this.self.contributionStatus = ContributionStatus.PENDING
this.self.resubmissionAt = null
} else {
this.changed = false
}
}
protected checkAuthorization(user: User): AbstractUnconfirmedContributionRole {
if (this.self.userId !== user.id) {
throw new LogError('Can not update contribution of another user', this.self, user.id)
}
// only admins and moderators can update it when status is other than progress or pending
// but we are in the user add message role.. we are currently not admin or moderator
if (
this.self.contributionStatus !== ContributionStatus.IN_PROGRESS &&
this.self.contributionStatus !== ContributionStatus.PENDING
) {
throw new LogError(
'Contribution can not be updated due to status',
this.self.contributionStatus,
)
}
if (this.updateData.messageType !== ContributionMessageType.DIALOG) {
throw new LogError('unexpected contribution message type, only dialog is allowed for user')
}
return this
}
public createContributionMessage(): ContributionMessageBuilder | undefined {
const builder = super.createContributionMessage()
if (builder) {
return builder.setIsModerator(false).setDialogType(this.updateData.message)
}
}
}

View File

@ -1,3 +1,4 @@
import { EntityManager, FindOneOptions, FindOptionsRelations } from '@dbTools/typeorm'
import { Contribution } from '@entity/Contribution'
import { ContributionMessage } from '@entity/ContributionMessage'
import { Decimal } from 'decimal.js-light'
@ -5,13 +6,15 @@ import { Decimal } from 'decimal.js-light'
import { AdminUpdateContributionArgs } from '@arg/AdminUpdateContributionArgs'
import { ContributionArgs } from '@arg/ContributionArgs'
import { ContributionMessageBuilder } from '@/data/ContributionMessage.builder'
import { ContributionMessageArgs } from '@/graphql/arg/ContributionMessageArgs'
import { Context } from '@/server/context'
import { LogError } from '@/server/LogError'
import { AbstractUnconfirmedContributionRole } from './AbstractUnconfirmedContribution.role'
import { UnconfirmedContributionAdminRole } from './UnconfirmedContributionAdmin.role'
import { UnconfirmedContributionAdminAddMessageRole } from './UnconfirmedContributionAdminAddMessage.role'
import { UnconfirmedContributionUserRole } from './UnconfirmedContributionUser.role'
import { UnconfirmedContributionUserAddMessageRole } from './UnconfirmedContributionUserAddMessage.role'
export class UpdateUnconfirmedContributionContext {
private oldMemoText: string
@ -23,7 +26,7 @@ export class UpdateUnconfirmedContributionContext {
*/
public constructor(
private id: number,
private input: ContributionArgs | AdminUpdateContributionArgs,
private input: ContributionArgs | AdminUpdateContributionArgs | ContributionMessageArgs,
private context: Context,
) {
if (!context.role || !context.user) {
@ -31,28 +34,31 @@ export class UpdateUnconfirmedContributionContext {
}
}
public async run(): Promise<{
public async run(
transactionEntityManager?: EntityManager,
relations?: FindOptionsRelations<Contribution>,
): Promise<{
contribution: Contribution
contributionMessage: ContributionMessage
contributionMessage: ContributionMessage | undefined
availableCreationSums: Decimal[]
createdByUserChangedByModerator: boolean
contributionChanged: boolean
}> {
let createdByUserChangedByModerator = false
if (!this.context.role || !this.context.user) {
throw new LogError("context didn't contain role or user")
}
const contributionToUpdate = await Contribution.findOne({
where: { id: this.id },
})
const options: FindOneOptions<Contribution> = { where: { id: this.id }, relations }
let contributionToUpdate: Contribution | null
if (transactionEntityManager) {
contributionToUpdate = await transactionEntityManager.findOne(Contribution, options)
} else {
contributionToUpdate = await Contribution.findOne(options)
}
if (!contributionToUpdate) {
throw new LogError('Contribution not found', this.id)
}
this.oldMemoText = contributionToUpdate.memo
const contributionMessageBuilder = new ContributionMessageBuilder()
contributionMessageBuilder
.setParentContribution(contributionToUpdate)
.setHistoryType(contributionToUpdate)
.setUser(this.context.user)
// choose correct role
let unconfirmedContributionRole: AbstractUnconfirmedContributionRole | null = null
@ -61,30 +67,49 @@ export class UpdateUnconfirmedContributionContext {
contributionToUpdate,
this.input,
)
contributionMessageBuilder.setIsModerator(false)
} else if (this.input instanceof AdminUpdateContributionArgs) {
unconfirmedContributionRole = new UnconfirmedContributionAdminRole(
contributionToUpdate,
this.input,
this.context.user,
)
if (unconfirmedContributionRole.isCreatedFromUser()) {
createdByUserChangedByModerator = true
}
contributionMessageBuilder.setIsModerator(true)
} else if (this.input instanceof ContributionMessageArgs) {
if (contributionToUpdate.userId !== this.context.user.id) {
unconfirmedContributionRole = new UnconfirmedContributionAdminAddMessageRole(
contributionToUpdate,
this.input,
)
} else {
unconfirmedContributionRole = new UnconfirmedContributionUserAddMessageRole(
contributionToUpdate,
this.input,
)
}
}
if (!unconfirmedContributionRole) {
throw new LogError("don't recognize input type, maybe not implemented yet?")
}
const contributionMessageBuilder = unconfirmedContributionRole.createContributionMessage()
if (contributionMessageBuilder) {
contributionMessageBuilder.setUser(this.context.user)
}
// run steps
// all possible cases not to be true are thrown in the next function
await unconfirmedContributionRole.checkAndUpdate(this.context)
return {
contribution: contributionToUpdate,
contributionMessage: contributionMessageBuilder.build(),
contributionMessage: contributionMessageBuilder
? contributionMessageBuilder.build()
: undefined,
availableCreationSums: unconfirmedContributionRole.getAvailableCreationSums(),
createdByUserChangedByModerator,
contributionChanged: unconfirmedContributionRole.isChanged(),
}
}

View File

@ -262,6 +262,7 @@ export const createContribution = gql`
id
amount
memo
userId
}
}
`

View File

@ -0,0 +1,107 @@
import { Decimal } from 'decimal.js-light'
import {
BaseEntity,
Column,
Entity,
PrimaryGeneratedColumn,
DeleteDateColumn,
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
} from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
import { User } from '../User'
import { ContributionMessage } from '../ContributionMessage'
import { Transaction } from '../Transaction'
@Entity('contributions')
export class Contribution extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ unsigned: true, nullable: false, name: 'user_id' })
userId: number
@ManyToOne(() => User, (user) => user.contributions)
@JoinColumn({ name: 'user_id' })
user: User
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt: Date
@Column({ type: 'datetime', name: 'resubmission_at', default: null, nullable: true })
resubmissionAt: Date | null
@Column({ type: 'datetime', nullable: false, name: 'contribution_date' })
contributionDate: Date
@Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
amount: Decimal
@Column({ unsigned: true, nullable: true, name: 'moderator_id' })
moderatorId: number
@Column({ unsigned: true, nullable: true, name: 'contribution_link_id' })
contributionLinkId: number
@Column({ unsigned: true, nullable: true, name: 'confirmed_by' })
confirmedBy: number
@Column({ nullable: true, name: 'confirmed_at' })
confirmedAt: Date
@Column({ unsigned: true, nullable: true, name: 'denied_by' })
deniedBy: number
@Column({ nullable: true, name: 'denied_at' })
deniedAt: Date
@Column({
name: 'contribution_type',
length: 12,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
contributionType: string
@Column({
name: 'contribution_status',
length: 12,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
contributionStatus: string
@Column({ unsigned: true, nullable: true, name: 'transaction_id' })
transactionId: number
@Column({ nullable: true, name: 'updated_at' })
updatedAt: Date
@Column({ nullable: true, unsigned: true, name: 'updated_by', type: 'int' })
updatedBy: number | null
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt: Date | null
@DeleteDateColumn({ unsigned: true, nullable: true, name: 'deleted_by' })
deletedBy: number
@OneToMany(() => ContributionMessage, (message) => message.contribution)
@JoinColumn({ name: 'contribution_id' })
messages?: ContributionMessage[]
@OneToOne(() => Transaction, (transaction) => transaction.contribution)
@JoinColumn({ name: 'transaction_id' })
transaction?: Transaction | null
}

View File

@ -0,0 +1,60 @@
import {
BaseEntity,
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { Contribution } from '../Contribution'
import { User } from '../User'
@Entity('contribution_messages', {
engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
})
export class ContributionMessage extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Index()
@Column({ name: 'contribution_id', unsigned: true, nullable: false })
contributionId: number
@ManyToOne(() => Contribution, (contribution) => contribution.messages)
@JoinColumn({ name: 'contribution_id' })
contribution: Contribution
@Column({ name: 'user_id', unsigned: true, nullable: false })
userId: number
@ManyToOne(() => User, (user) => user.messages)
@JoinColumn({ name: 'user_id' })
user: User
@Column({ length: 2000, nullable: false, collation: 'utf8mb4_unicode_ci' })
message: string
@CreateDateColumn()
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt: Date
@UpdateDateColumn()
@Column({ type: 'datetime', default: null, nullable: true, name: 'updated_at' })
updatedAt: Date
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt: Date | null
@Column({ name: 'deleted_by', default: null, unsigned: true, nullable: true })
deletedBy: number
@Column({ length: 12, nullable: false, collation: 'utf8mb4_unicode_ci' })
type: string
@Column({ name: 'is_moderator', type: 'bool', nullable: false, default: false })
isModerator: boolean
}

View File

@ -1 +1 @@
export { Contribution } from './0076-add_updated_by_contribution/Contribution'
export { Contribution } from './0078-move_resubmission_date/Contribution'

View File

@ -1 +1 @@
export { ContributionMessage } from './0077-add_resubmission_date_contribution_message/ContributionMessage'
export { ContributionMessage } from './0078-move_resubmission_date/ContributionMessage'

View File

@ -0,0 +1,16 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
`ALTER TABLE \`contributions\` ADD COLUMN \`resubmission_at\` datetime NULL DEFAULT NULL AFTER \`created_at\`;`,
)
await queryFn(`ALTER TABLE \`contribution_messages\` DROP COLUMN \`resubmission_at\`;`)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
`ALTER TABLE \`contribution_messages\` ADD COLUMN \`resubmission_at\` datetime NULL DEFAULT NULL AFTER \`deleted_by\`;`,
)
await queryFn(`ALTER TABLE \`contributions\` DROP COLUMN \`resubmission_at\`;`)
}

View File

@ -4,7 +4,7 @@ import dotenv from 'dotenv'
dotenv.config()
const constants = {
DB_VERSION: '0077-add_resubmission_date_contribution_message',
DB_VERSION: '0078-move_resubmission_date',
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL || 'info',

View File

@ -10,7 +10,7 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0077-add_resubmission_date_contribution_message',
DB_VERSION: '0078-move_resubmission_date',
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