diff --git a/server/constants/goal-types.ts b/server/constants/goal-types.ts new file mode 100644 index 00000000000..2eb68a18eec --- /dev/null +++ b/server/constants/goal-types.ts @@ -0,0 +1,14 @@ +/** + * Constants for the goal type + * + */ + +enum GoalTypes { + ALL_TIME = 'ALL_TIME', + MONTHLY_BUDGET = 'MONTHLY_BUDGET', + YEARLY_BUDGET = 'YEARLY_BUDGET', + CALENDAR_MONTH = 'CALENDAR_MONTH', + CALENDAR_YEAR = 'CALENDAR_YEAR', +} + +export default GoalTypes; diff --git a/server/graphql/schemaV2.graphql b/server/graphql/schemaV2.graphql index 766fed191b9..f8e0b03046a 100644 --- a/server/graphql/schemaV2.graphql +++ b/server/graphql/schemaV2.graphql @@ -990,6 +990,22 @@ interface Account { EXPERIMENTAL (this may change or be removed) """ transactionReports(timeUnit: TimeUnit = MONTH, dateFrom: DateTime, dateTo: DateTime): TransactionReports + goal: Goal + activeContributors( + """ + The number of results to fetch (default 10, max 1000) + """ + limit: Int! = 10 + + """ + The offset to use to fetch + """ + offset: Int! = 0 + forGoalType: GoalType + dateFrom: DateTime + dateTo: DateTime + includeActiveRecurringContributions: Boolean + ): AccountCollection } """ @@ -3430,6 +3446,22 @@ type Host implements Account & AccountWithContributions { EXPERIMENTAL (this may change or be removed) """ transactionReports(timeUnit: TimeUnit = MONTH, dateFrom: DateTime, dateTo: DateTime): TransactionReports + goal: Goal + activeContributors( + """ + The number of results to fetch (default 10, max 1000) + """ + limit: Int! = 10 + + """ + The offset to use to fetch + """ + offset: Int! = 0 + forGoalType: GoalType + dateFrom: DateTime + dateTo: DateTime + includeActiveRecurringContributions: Boolean + ): AccountCollection webhooks( """ The number of results to fetch (default 10, max 1000) @@ -6265,6 +6297,64 @@ type TransactionsAmountGroup { expenseType: ExpenseType } +type Goal { + """ + The type of the goal (per month, per year or all time) + """ + type: GoalType + + """ + The amount of the goal + """ + amount: Amount + + """ + The progress of the goal in percentage + """ + progress: Int + contributors( + """ + The number of results to fetch (default 10, max 1000) + """ + limit: Int! = 10 + + """ + The offset to use to fetch + """ + offset: Int! = 0 + ): AccountCollection +} + +""" +All supported goal types +""" +enum GoalType { + """ + Total contributions + """ + ALL_TIME + + """ + Active yearly contributions (divided by 12), active monthly contributions and one-time contributions in the past 30 days + """ + MONTHLY_BUDGET + + """ + Active yearly contributions , active monthly contributions (times 12) and one-time contributions in the past 365 days + """ + YEARLY_BUDGET + + """ + Contributions in the current calendar month + """ + CALENDAR_MONTH + + """ + Contributions in the current calendar year + """ + CALENDAR_YEAR +} + """ A collection of webhooks """ @@ -8413,6 +8503,22 @@ type Bot implements Account { EXPERIMENTAL (this may change or be removed) """ transactionReports(timeUnit: TimeUnit = MONTH, dateFrom: DateTime, dateTo: DateTime): TransactionReports + goal: Goal + activeContributors( + """ + The number of results to fetch (default 10, max 1000) + """ + limit: Int! = 10 + + """ + The offset to use to fetch + """ + offset: Int! = 0 + forGoalType: GoalType + dateFrom: DateTime + dateTo: DateTime + includeActiveRecurringContributions: Boolean + ): AccountCollection webhooks( """ The number of results to fetch (default 10, max 1000) @@ -9260,6 +9366,22 @@ type Collective implements Account & AccountWithHost & AccountWithContributions EXPERIMENTAL (this may change or be removed) """ transactionReports(timeUnit: TimeUnit = MONTH, dateFrom: DateTime, dateTo: DateTime): TransactionReports + goal: Goal + activeContributors( + """ + The number of results to fetch (default 10, max 1000) + """ + limit: Int! = 10 + + """ + The offset to use to fetch + """ + offset: Int! = 0 + forGoalType: GoalType + dateFrom: DateTime + dateTo: DateTime + includeActiveRecurringContributions: Boolean + ): AccountCollection webhooks( """ The number of results to fetch (default 10, max 1000) @@ -10560,6 +10682,22 @@ type Event implements Account & AccountWithHost & AccountWithContributions & Acc EXPERIMENTAL (this may change or be removed) """ transactionReports(timeUnit: TimeUnit = MONTH, dateFrom: DateTime, dateTo: DateTime): TransactionReports + goal: Goal + activeContributors( + """ + The number of results to fetch (default 10, max 1000) + """ + limit: Int! = 10 + + """ + The offset to use to fetch + """ + offset: Int! = 0 + forGoalType: GoalType + dateFrom: DateTime + dateTo: DateTime + includeActiveRecurringContributions: Boolean + ): AccountCollection webhooks( """ The number of results to fetch (default 10, max 1000) @@ -11640,6 +11778,22 @@ type Individual implements Account { EXPERIMENTAL (this may change or be removed) """ transactionReports(timeUnit: TimeUnit = MONTH, dateFrom: DateTime, dateTo: DateTime): TransactionReports + goal: Goal + activeContributors( + """ + The number of results to fetch (default 10, max 1000) + """ + limit: Int! = 10 + + """ + The offset to use to fetch + """ + offset: Int! = 0 + forGoalType: GoalType + dateFrom: DateTime + dateTo: DateTime + includeActiveRecurringContributions: Boolean + ): AccountCollection webhooks( """ The number of results to fetch (default 10, max 1000) @@ -12693,6 +12847,22 @@ type Organization implements Account & AccountWithContributions { EXPERIMENTAL (this may change or be removed) """ transactionReports(timeUnit: TimeUnit = MONTH, dateFrom: DateTime, dateTo: DateTime): TransactionReports + goal: Goal + activeContributors( + """ + The number of results to fetch (default 10, max 1000) + """ + limit: Int! = 10 + + """ + The offset to use to fetch + """ + offset: Int! = 0 + forGoalType: GoalType + dateFrom: DateTime + dateTo: DateTime + includeActiveRecurringContributions: Boolean + ): AccountCollection webhooks( """ The number of results to fetch (default 10, max 1000) @@ -13637,6 +13807,22 @@ type Vendor implements Account & AccountWithContributions { EXPERIMENTAL (this may change or be removed) """ transactionReports(timeUnit: TimeUnit = MONTH, dateFrom: DateTime, dateTo: DateTime): TransactionReports + goal: Goal + activeContributors( + """ + The number of results to fetch (default 10, max 1000) + """ + limit: Int! = 10 + + """ + The offset to use to fetch + """ + offset: Int! = 0 + forGoalType: GoalType + dateFrom: DateTime + dateTo: DateTime + includeActiveRecurringContributions: Boolean + ): AccountCollection webhooks( """ The number of results to fetch (default 10, max 1000) @@ -17213,6 +17399,22 @@ type Fund implements Account & AccountWithHost & AccountWithContributions { EXPERIMENTAL (this may change or be removed) """ transactionReports(timeUnit: TimeUnit = MONTH, dateFrom: DateTime, dateTo: DateTime): TransactionReports + goal: Goal + activeContributors( + """ + The number of results to fetch (default 10, max 1000) + """ + limit: Int! = 10 + + """ + The offset to use to fetch + """ + offset: Int! = 0 + forGoalType: GoalType + dateFrom: DateTime + dateTo: DateTime + includeActiveRecurringContributions: Boolean + ): AccountCollection webhooks( """ The number of results to fetch (default 10, max 1000) @@ -18173,6 +18375,22 @@ type Project implements Account & AccountWithHost & AccountWithContributions & A EXPERIMENTAL (this may change or be removed) """ transactionReports(timeUnit: TimeUnit = MONTH, dateFrom: DateTime, dateTo: DateTime): TransactionReports + goal: Goal + activeContributors( + """ + The number of results to fetch (default 10, max 1000) + """ + limit: Int! = 10 + + """ + The offset to use to fetch + """ + offset: Int! = 0 + forGoalType: GoalType + dateFrom: DateTime + dateTo: DateTime + includeActiveRecurringContributions: Boolean + ): AccountCollection webhooks( """ The number of results to fetch (default 10, max 1000) @@ -19210,6 +19428,18 @@ type Mutation { emailConfirmationToken: String! ): ConfirmGuestAccountResponse! + """ + Set a goal for your account. + """ + setGoal( + account: AccountReferenceInput! + + """ + The goal to set for the account. Setting goal to undefined or null will remove any current goal. + """ + goal: GoalInput + ): Goal + """ Apply to an host with a collective. Scope: "account". """ @@ -21479,6 +21709,14 @@ type ConfirmGuestAccountResponse { accessToken: String! } +""" +Input type for Goals +""" +input GoalInput { + type: GoalType! + amount: Int! +} + type ProcessHostApplicationResponse { """ The account that applied to the host diff --git a/server/graphql/v2/enum/GoalType.ts b/server/graphql/v2/enum/GoalType.ts new file mode 100644 index 00000000000..8bf79a609c9 --- /dev/null +++ b/server/graphql/v2/enum/GoalType.ts @@ -0,0 +1,27 @@ +import { GraphQLEnumType } from 'graphql'; + +import goalType from '../../../constants/goal-types'; + +export const GraphQLGoalType = new GraphQLEnumType({ + name: 'GoalType', + description: 'All supported goal types', + values: { + [goalType.ALL_TIME]: { + description: 'Total contributions', + }, + [goalType.MONTHLY_BUDGET]: { + description: + 'Active yearly contributions (divided by 12), active monthly contributions and one-time contributions in the past 30 days', + }, + [goalType.YEARLY_BUDGET]: { + description: + 'Active yearly contributions , active monthly contributions (times 12) and one-time contributions in the past 365 days', + }, + [goalType.CALENDAR_MONTH]: { + description: 'Contributions in the current calendar month', + }, + [goalType.CALENDAR_YEAR]: { + description: 'Contributions in the current calendar year', + }, + }, +}); diff --git a/server/graphql/v2/input/GoalInput.ts b/server/graphql/v2/input/GoalInput.ts new file mode 100644 index 00000000000..fe15e06010e --- /dev/null +++ b/server/graphql/v2/input/GoalInput.ts @@ -0,0 +1,12 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLNonNull } from 'graphql'; + +import { GraphQLGoalType } from '../enum/GoalType'; + +export const GraphQLGoalInput = new GraphQLInputObjectType({ + name: 'GoalInput', + description: 'Input type for Goals', + fields: () => ({ + type: { type: new GraphQLNonNull(GraphQLGoalType) }, + amount: { type: new GraphQLNonNull(GraphQLInt) }, + }), +}); diff --git a/server/graphql/v2/interface/Account.ts b/server/graphql/v2/interface/Account.ts index 34868e8a2d3..4073e914d24 100644 --- a/server/graphql/v2/interface/Account.ts +++ b/server/graphql/v2/interface/Account.ts @@ -6,6 +6,7 @@ import { Order, Sequelize } from 'sequelize'; import { CollectiveType } from '../../../constants/collectives'; import FEATURE from '../../../constants/feature'; +import GoalTypes from '../../../constants/goal-types'; import { buildSearchConditions } from '../../../lib/sql-search'; import { getCollectiveFeed } from '../../../lib/timeline'; import { getAccountReportNodesFromQueryResult } from '../../../lib/transaction-reports'; @@ -66,6 +67,7 @@ import { GraphQLAccountStats } from '../object/AccountStats'; import { GraphQLActivity } from '../object/Activity'; import { GraphQLActivitySubscription } from '../object/ActivitySubscription'; import { GraphQLConnectedAccount } from '../object/ConnectedAccount'; +import { GraphQLGoal } from '../object/Goal'; import { GraphQLLegalDocument } from '../object/LegalDocument'; import { GraphQLLocation } from '../object/Location'; import { GraphQLMemberInvitation } from '../object/MemberInvitation'; @@ -94,6 +96,7 @@ import GraphQLEmailAddress from '../scalar/EmailAddress'; import { CollectionArgs } from './Collection'; import { HasMembersFields } from './HasMembers'; import { IsMemberOfFields } from './IsMemberOf'; +import { GraphQLGoalType } from '../enum/GoalType'; const accountFieldsDefinition = () => ({ id: { @@ -992,6 +995,110 @@ const accountFieldsDefinition = () => ({ }; }, }, + goal: { + type: GraphQLGoal, + async resolve(account, _, req) { + const goal = account.settings.goal; + if (!goal) { + return null; + } + + let currentAmountProgress; + + if (goal.type === GoalTypes.MONTHLY_BUDGET) { + currentAmountProgress = (await account.getYearlyBudget({ loaders: req.loaders })) / 12; + } else if (goal.type === GoalTypes.YEARLY_BUDGET) { + currentAmountProgress = await account.getYearlyBudget({ loaders: req.loaders }); + } else { + currentAmountProgress = await account.getTotalAmountReceived({ loaders: req.loaders, net: true }); + } + const progress = Math.floor((currentAmountProgress / goal.amount) * 100); + + return { + ...goal, + amount: { + value: goal.amount, + currency: account.currency, + }, + progress, + accountId: account.id, + }; + }, + }, + activeContributors: { + type: GraphQLAccountCollection, + args: { + ...CollectionArgs, + forGoalType: { type: GraphQLGoalType }, + dateFrom: { type: GraphQLDateTime }, + dateTo: { type: GraphQLDateTime }, + includeActiveRecurringContributions: { type: GraphQLBoolean }, + }, + async resolve(account, args) { + const collectiveIdsResult = await sequelize.query( + `WITH "CollectiveDonations" AS ( + SELECT + "Orders"."FromCollectiveId", + SUM("Transactions".amount) AS total_donated + FROM "Orders" + JOIN "Transactions" ON "Transactions"."OrderId" = "Orders".id + WHERE "Orders"."CollectiveId" = :accountId + ${ + args.includeActiveRecurringContributions + ? ` + AND ( + ("Orders".status = 'ACTIVE' AND "Orders".interval IN ('month', 'year')) + OR ("Orders".status = 'PAID' AND "Orders"."createdAt" >= :dateFrom) + )` + : '' + } + + AND "Transactions".type = 'CREDIT' + AND "Transactions"."CollectiveId" = :accountId + AND "Transactions"."FromCollectiveId" = "Orders"."FromCollectiveId" + AND "Transactions"."isRefund" = FALSE + AND "Transactions"."RefundTransactionId" IS NULL + AND "Transactions"."deletedAt" IS NULL + AND "Orders"."deletedAt" IS NULL + ${!args.includeActiveRecurringContributions && args.dateTo ? `AND "Transactions"."createdAt" <= :dateTo` : ''} + ${!args.includeActiveRecurringContributions && args.dateFrom ? `AND "Transactions"."createdAt" >= :dateFrom` : ''} + GROUP BY "Orders"."FromCollectiveId" + ) + SELECT "Collectives".id + FROM "Collectives" + JOIN "CollectiveDonations" ON "Collectives".id = "CollectiveDonations"."FromCollectiveId" + WHERE "Collectives"."deletedAt" IS NULL + ORDER BY "CollectiveDonations".total_donated DESC; + `, + { + replacements: { + accountId: account.id, + dateFrom: args.dateFrom, + dateTo: args.dateTo, + }, + type: sequelize.QueryTypes.SELECT, + }, + ); + + const collectiveIds = collectiveIdsResult.map(result => result.id); + + const collectives = await models.Collective.findAll({ + where: { + id: collectiveIds, + }, + order: [['id', 'DESC']], // To maintain the order of total donations + offset: args.offset, + limit: args.limit, + }); + + return { + totalCount: collectiveIdsResult.length, + nodes: collectives, + limit: args.limit, + offset: args.offset, + }; + }, + }, }); export const GraphQLAccount = new GraphQLInterfaceType({ diff --git a/server/graphql/v2/mutation/GoalMutations.ts b/server/graphql/v2/mutation/GoalMutations.ts new file mode 100644 index 00000000000..54f4a0e1eb4 --- /dev/null +++ b/server/graphql/v2/mutation/GoalMutations.ts @@ -0,0 +1,72 @@ +import { GraphQLNonNull } from 'graphql'; +import { cloneDeep, set } from 'lodash'; + +import activities from '../../../constants/activities'; +import models, { sequelize } from '../../../models'; +import { checkRemoteUserCanUseAccount } from '../../common/scope-check'; +import { Forbidden, Unauthorized } from '../../errors'; +import { fetchAccountWithReference, GraphQLAccountReferenceInput } from '../input/AccountReferenceInput'; +import { GraphQLGoalInput } from '../input/GoalInput'; +import { GraphQLGoal } from '../object/Goal'; + +const goalMutations = { + setGoal: { + type: GraphQLGoal, + description: 'Set a goal for your account.', + args: { + account: { + type: new GraphQLNonNull(GraphQLAccountReferenceInput), + }, + goal: { + type: GraphQLGoalInput, + description: 'The goal to set for the account. Setting goal to undefined or null will remove any current goal.', + }, + }, + async resolve(_: void, args: Record, req: Express.Request): Promise { + if (!req.remoteUser) { + throw new Unauthorized(); + } + + return sequelize.transaction(async transaction => { + const account = await fetchAccountWithReference(args.account, { + throwIfMissing: true, + lock: true, + dbTransaction: transaction, + }); + + if (!req.remoteUser.isAdminOfCollective(account)) { + throw new Forbidden(); + } + + checkRemoteUserCanUseAccount(req); + + const settings = account.settings ? cloneDeep(account.settings) : {}; + set(settings, 'goal', args.goal); + // Remove legacy goals + set(settings, 'goals', undefined); + const previousData = { + settings: { goal: account.settings?.goal, ...(account.settings?.goals && { goals: account.settings.goals }) }, + }; + const updatedAccount = await account.update({ settings }, { transaction }); + await models.Activity.create( + { + type: activities.COLLECTIVE_EDITED, + UserId: req.remoteUser.id, + UserTokenId: req.userToken?.id, + CollectiveId: account.id, + FromCollectiveId: account.id, + HostCollectiveId: account.approvedAt ? account.HostCollectiveId : null, + data: { + previousData, + newData: { settings: { goal: args.goal } }, + }, + }, + { transaction }, + ); + return updatedAccount.settings.goal; + }); + }, + }, +}; + +export default goalMutations; diff --git a/server/graphql/v2/mutation/index.js b/server/graphql/v2/mutation/index.js index 2401a6bfb29..073f87cc3d1 100644 --- a/server/graphql/v2/mutation/index.js +++ b/server/graphql/v2/mutation/index.js @@ -15,6 +15,7 @@ import createProjectMutation from './CreateProjectMutation'; import emojiReactionMutations from './EmojiReactionMutations'; import expenseMutations from './ExpenseMutations'; import guestMutations from './GuestMutations'; +import goalMutations from './GoalMutations'; import hostApplicationMutations from './HostApplicationMutations'; import individualMutations from './IndividualMutations'; import { legalDocumentsMutations } from './LegalDocumentsMutations'; @@ -55,6 +56,7 @@ const mutation = { ...emojiReactionMutations, ...expenseMutations, ...guestMutations, + ...goalMutations, ...hostApplicationMutations, ...individualMutations, ...legalDocumentsMutations, diff --git a/server/graphql/v2/object/Goal.ts b/server/graphql/v2/object/Goal.ts new file mode 100644 index 00000000000..fc891b7b00e --- /dev/null +++ b/server/graphql/v2/object/Goal.ts @@ -0,0 +1,89 @@ +import { GraphQLInt, GraphQLObjectType } from 'graphql'; +import moment from 'moment'; + +import models, { sequelize } from '../../../models'; +import { GraphQLAccountCollection } from '../collection/AccountCollection'; +import { GraphQLGoalType } from '../enum/GoalType'; +import { CollectionArgs } from '../interface/Collection'; + +import { GraphQLAmount } from './Amount'; +import GoalTypes from '../../../constants/goal-types'; + +export const GraphQLGoal = new GraphQLObjectType({ + name: 'Goal', + fields: () => ({ + type: { + type: GraphQLGoalType, + description: 'The type of the goal (per month, per year or all time)', + }, + amount: { + type: GraphQLAmount, + description: 'The amount of the goal', + }, + progress: { + type: GraphQLInt, + description: 'The progress of the goal in percentage', + }, + contributors: { + type: GraphQLAccountCollection, + args: CollectionArgs, + async resolve(goal, args) { + const collectiveIdsResult = await sequelize.query( + `WITH "CollectiveDonations" AS ( + SELECT + "Orders"."FromCollectiveId", + SUM("Transactions".amount) AS total_donated + FROM "Orders" + JOIN "Transactions" ON "Transactions"."OrderId" = "Orders".id + WHERE "Orders"."CollectiveId" = :accountId + AND ( + ("Orders".status = 'ACTIVE' AND "Orders".interval IN ('month', 'year')) + OR ("Orders".status = 'PAID' ${[GoalTypes.MONTHLY_BUDGET, GoalTypes.YEARLY_BUDGET].includes(goal.type) ? 'AND "Orders"."createdAt" >= :dateFrom' : ''}) + ) + AND "Transactions".type = 'CREDIT' + AND "Transactions"."CollectiveId" = :accountId + AND "Transactions"."FromCollectiveId" = "Orders"."FromCollectiveId" + AND "Transactions"."isRefund" = FALSE + AND "Transactions"."RefundTransactionId" IS NULL + AND "Transactions"."deletedAt" IS NULL + AND "Orders"."deletedAt" IS NULL + GROUP BY "Orders"."FromCollectiveId" + ) + SELECT "Collectives".id + FROM "Collectives" + JOIN "CollectiveDonations" ON "Collectives".id = "CollectiveDonations"."FromCollectiveId" + WHERE "Collectives"."deletedAt" IS NULL + ORDER BY "CollectiveDonations".total_donated DESC; + `, + { + replacements: { + accountId: goal.accountId, + dateFrom: [GoalTypes.MONTHLY_BUDGET, GoalTypes.YEARLY_BUDGET].includes(goal.type) + ? moment().utc().subtract(1, 'year').toDate().toISOString() + : undefined, + }, + type: sequelize.QueryTypes.SELECT, + }, + ); + + const collectiveIds = collectiveIdsResult.map(result => result.id); + + const collectives = await models.Collective.findAll({ + where: { + id: collectiveIds, + }, + order: [['id', 'DESC']], // To maintain the order of total donations + offset: args.offset, + limit: args.limit, + }); + + return { + totalCount: collectiveIdsResult.length, + nodes: collectives, + limit: args.limit, + offset: args.offset, + }; + }, + }, + }), +}); diff --git a/server/models/Collective.ts b/server/models/Collective.ts index a16ed82aa8c..d3bd065ae44 100644 --- a/server/models/Collective.ts +++ b/server/models/Collective.ts @@ -133,11 +133,16 @@ import VirtualCard from './VirtualCard'; const debug = debugLib('models:Collective'); -type Goal = { +type LegacyGoal = { type: string; amount: number; }; +type Goal = { + type: 'YEARLY' | 'MONTHLY' | 'ALL_TIME'; + amount: number; +}; + type TaxSettings = { [key in 'VAT' | 'GST' | 'EIN']?: { number: string; @@ -146,7 +151,8 @@ type TaxSettings = { }; type Settings = { - goals?: Array; + goals?: Array; + goal?: Goal; disablePublicExpenseSubmission?: boolean; isPlatformRevenueDirectlyCollected?: boolean; features?: { @@ -577,7 +583,7 @@ class Collective extends Model< * Used for the monthly reports to backers */ getNextGoal = async function (until) { - const goals = >get(this, 'settings.goals'); + const goals = >get(this, 'settings.goals'); if (!goals) { return null; }