diff --git a/package-lock.json b/package-lock.json index 3136cef5e60..3069b887bdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "fast-redact": "3.5.0", "get-urls": "10.0.1", "gqlmin": "0.3.2", - "graphql": "16.14.1", + "graphql": "17.0.1", "graphql-scalars": "1.25.0", "graphql-upload": "15.0.2", "handlebars": "4.7.9", @@ -13826,12 +13826,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphql": { - "version": "16.14.1", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.1.tgz", - "integrity": "sha512-cQOsSMS/IrDz82PVyRDvf/Q1F/bRbBVjJlh+xYOkI1qw2bWRvWGiWc+m2O0d6l4Bt1fyY+8kzJ8JFWGJqNeDBg==", + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-17.0.1.tgz", + "integrity": "sha512-8eWbg5Zcv/8o20nzEjHUGPTj20MLFJjc5kagbIPxbaeGxvFwpitJhemEC/k17n5+UD4M/9ea5rTuce78mELujQ==", "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + "node": "^22.0.0 || ^24.0.0 || ^25.0.0 || >=26.0.0" } }, "node_modules/graphql-config": { diff --git a/package.json b/package.json index f33b02c708e..9665f550b01 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "fast-redact": "3.5.0", "get-urls": "10.0.1", "gqlmin": "0.3.2", - "graphql": "16.14.1", + "graphql": "17.0.1", "graphql-scalars": "1.25.0", "graphql-upload": "15.0.2", "handlebars": "4.7.9", @@ -279,7 +279,8 @@ ] }, "overrides": { - "url-regex-safe": "4.0.0" + "url-regex-safe": "4.0.0", + "graphql": "17.0.1" }, "nyc": { "reporter": [ diff --git a/server/graphql/schemaV1.graphql b/server/graphql/schemaV1.graphql index d1823cf9968..f4e466fd491 100644 --- a/server/graphql/schemaV1.graphql +++ b/server/graphql/schemaV1.graphql @@ -131,9 +131,9 @@ interface CollectiveInterface { When true (default), exclude tiers with types that are no longer supported. Use false for edit pages to show all tiers. """ onlyValid: Boolean = true - ): [Tier] + ): [Tier] @deprecated(reason: "2025-12-05: Will be deleted soon. Use GraphQL v2") orders(status: OrderStatus): [OrderType] - stats: CollectiveStatsType + stats: CollectiveStatsType @deprecated(reason: "2025-07-10: Please use GraphQL V2") """ The list of expense types supported by this account @@ -200,7 +200,7 @@ interface CollectiveInterface { Describes the features enabled and available for this collective """ features: CollectiveFeatures! - plan: PlanType + plan: PlanType @deprecated(reason: "2026-04-02: Replaced by new pricing") contributionPolicy: String """ diff --git a/server/graphql/schemaV2.graphql b/server/graphql/schemaV2.graphql index 1688e59607a..301a258fc17 100644 --- a/server/graphql/schemaV2.graphql +++ b/server/graphql/schemaV2.graphql @@ -3890,7 +3890,7 @@ type Host implements Account & AccountWithContributions & AccountWithPlatformSub ): OAuthApplicationCollection "\n Address. This field is public for hosts, otherwise:\n - Users can see the addresses of the collectives they're admin of; if they are not an admin they can only see the country that the org belong to.\n - Hosts can see the address of organizations submitting expenses to their collectives.\n " - location: Location @deprecated(reason: "2026-04-22: This field has moved to the Organization type") + location: Location categories: [String]! stats: AccountStats diff --git a/server/graphql/v1/CollectiveInterface.js b/server/graphql/v1/CollectiveInterface.js index f3d31ac14c8..6d4f5c4df10 100644 --- a/server/graphql/v1/CollectiveInterface.js +++ b/server/graphql/v1/CollectiveInterface.js @@ -350,6 +350,7 @@ export const CollectiveInterfaceType = new GraphQLInterfaceType({ }, tiers: { type: new GraphQLList(TierType), + deprecationReason: '2025-12-05: Will be deleted soon. Use GraphQL v2', args: { id: { type: GraphQLInt }, slug: { type: GraphQLString }, @@ -368,7 +369,10 @@ export const CollectiveInterfaceType = new GraphQLInterfaceType({ status: { type: OrderStatusType }, }, }, - stats: { type: CollectiveStatsType }, + stats: { + type: CollectiveStatsType, + deprecationReason: '2025-07-10: Please use GraphQL V2', + }, supportedExpenseTypes: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString))), description: 'The list of expense types supported by this account', @@ -448,7 +452,10 @@ export const CollectiveInterfaceType = new GraphQLInterfaceType({ type: new GraphQLNonNull(GraphQLCollectiveFeatures), description: 'Describes the features enabled and available for this collective', }, - plan: { type: PlanType }, + plan: { + type: PlanType, + deprecationReason: '2026-04-02: Replaced by new pricing', + }, contributionPolicy: { type: GraphQLString }, categories: { type: new GraphQLNonNull(new GraphQLList(GraphQLString)), diff --git a/server/graphql/v2/collection/WebhookCollection.ts b/server/graphql/v2/collection/WebhookCollection.ts index f4df177c175..9e5399e5eab 100644 --- a/server/graphql/v2/collection/WebhookCollection.ts +++ b/server/graphql/v2/collection/WebhookCollection.ts @@ -5,7 +5,13 @@ import models from '../../../models'; import { checkRemoteUserCanUseWebhooks } from '../../common/scope-check'; import { Forbidden } from '../../errors'; import { fetchAccountWithReference, GraphQLAccountReferenceInput } from '../input/AccountReferenceInput'; -import { CollectionArgs, CollectionFields, GraphQLCollection } from '../interface/Collection'; +import { + COLLECTION_DEFAULT_LIMIT, + COLLECTION_DEFAULT_OFFSET, + CollectionArgs, + CollectionFields, + GraphQLCollection, +} from '../interface/Collection'; import { GraphQLWebhook } from '../object/Webhook'; export const GraphQLWebhookCollection = new GraphQLObjectType({ @@ -33,10 +39,10 @@ export const WebhookCollectionResolver = async (args, req: express.Request) => { // Check Pagination arguments if (args.limit <= 0) { - args.limit = CollectionArgs.limit.defaultValue; + args.limit = COLLECTION_DEFAULT_LIMIT; } if (args.offset <= 0) { - args.offset = CollectionArgs.offset.defaultValue; + args.offset = COLLECTION_DEFAULT_OFFSET; } // Check and Fetch account diff --git a/server/graphql/v2/input/AccountUpdateInput.js b/server/graphql/v2/input/AccountUpdateInput.js index cb95a0b3c7d..e7abd576515 100644 --- a/server/graphql/v2/input/AccountUpdateInput.js +++ b/server/graphql/v2/input/AccountUpdateInput.js @@ -68,7 +68,7 @@ export const GraphQLAccountUpdateInput = new GraphQLInputObjectType({ timezone: { description: 'Timezone of the Event (TZ database format, e.g. UTC or Europe/Berlin)', type: GraphQLString, - default: 'UTC', + default: { value: 'UTC' }, }, privateInstructions: { type: GraphQLString, diff --git a/server/graphql/v2/input/EventCreateInput.ts b/server/graphql/v2/input/EventCreateInput.ts index 5185b6b2318..616b9bf88f7 100644 --- a/server/graphql/v2/input/EventCreateInput.ts +++ b/server/graphql/v2/input/EventCreateInput.ts @@ -25,7 +25,7 @@ export const GraphQLEventCreateInput = new GraphQLInputObjectType({ timezone: { description: 'Timezone of the Event (TZ database format, e.g. UTC or Europe/Berlin)', type: new GraphQLNonNull(GraphQLString), - default: 'UTC', + default: { value: 'UTC' }, }, ...AccountImagesInputFields, }), diff --git a/server/graphql/v2/interface/AccountWithContributions.ts b/server/graphql/v2/interface/AccountWithContributions.ts index 6904e48d61b..a97e3fd9c90 100644 --- a/server/graphql/v2/interface/AccountWithContributions.ts +++ b/server/graphql/v2/interface/AccountWithContributions.ts @@ -22,7 +22,7 @@ import { GraphQLContributorCollection } from '../collection/ContributorCollectio import { GraphQLTierCollection } from '../collection/TierCollection'; import { GraphQLAccountType, GraphQLMemberRole, GraphQLTierType } from '../enum'; -import { CollectionArgs } from './Collection'; +import { CollectionArgs, collectionLimitArg } from './Collection'; export const AccountWithContributionsFields = { totalFinancialContributors: { @@ -53,11 +53,7 @@ export const AccountWithContributionsFields = { type: new GraphQLNonNull(GraphQLTierCollection), args: { ...CollectionArgs, - limit: { - type: new GraphQLNonNull(GraphQLInt), - description: 'The number of results to fetch', - defaultValue: 100, - }, + limit: collectionLimitArg(100), onlyValid: { type: GraphQLBoolean, description: diff --git a/server/graphql/v2/interface/Collection.ts b/server/graphql/v2/interface/Collection.ts index bdbf5c829d4..f8f491ac131 100644 --- a/server/graphql/v2/interface/Collection.ts +++ b/server/graphql/v2/interface/Collection.ts @@ -30,35 +30,36 @@ const GraphQLCollection = new GraphQLInterfaceType({ fields: () => CollectionFields, }); +export const COLLECTION_DEFAULT_LIMIT = 10; +export const COLLECTION_DEFAULT_OFFSET = 0; + +export const collectionLimitArg = (defaultLimit = COLLECTION_DEFAULT_LIMIT) => ({ + type: new GraphQLNonNull(GraphQLInt), + description: 'The number of results to fetch (default 10, max 1000)', + default: { value: defaultLimit }, +}); + +export const collectionOffsetArg = (defaultOffset = COLLECTION_DEFAULT_OFFSET) => ({ + type: new GraphQLNonNull(GraphQLInt), + description: 'The offset to use to fetch', + default: { value: defaultOffset }, +}); + /** * Types to use as arguments for fields that return types * that implement the Collection interface. */ const CollectionArgs = { - limit: { - type: new GraphQLNonNull(GraphQLInt), - description: 'The number of results to fetch (default 10, max 1000)', - defaultValue: 10, - }, - offset: { - type: new GraphQLNonNull(GraphQLInt), - description: 'The offset to use to fetch', - defaultValue: 0, - }, + limit: collectionLimitArg(), + offset: collectionOffsetArg(), }; /** * A helper to return `CollectionArgs` with custom defaults */ -export const getCollectionArgs = ({ limit = 10, offset = 0 }) => ({ - limit: { - ...CollectionArgs.limit, - defaultValue: limit ?? CollectionArgs.limit.defaultValue, - }, - offset: { - ...CollectionArgs.offset, - defaultValue: offset ?? CollectionArgs.offset.defaultValue, - }, +export const getCollectionArgs = ({ limit = COLLECTION_DEFAULT_LIMIT, offset = COLLECTION_DEFAULT_OFFSET } = {}) => ({ + limit: collectionLimitArg(limit ?? COLLECTION_DEFAULT_LIMIT), + offset: collectionOffsetArg(offset ?? COLLECTION_DEFAULT_OFFSET), }); export interface CollectionReturnType { diff --git a/server/graphql/v2/object/Host.ts b/server/graphql/v2/object/Host.ts index b8443c48739..d5e449cb5f4 100644 --- a/server/graphql/v2/object/Host.ts +++ b/server/graphql/v2/object/Host.ts @@ -1506,7 +1506,6 @@ export const GraphQLHost = new GraphQLObjectType({ }, ...mapValues( pick(getOrganizationFields(), [ - 'location', 'accountingCategories', 'contributionAccountingCategoryRules', 'hasMoneyManagement', diff --git a/server/graphql/v2/query/collection/ActivitiesCollectionQuery.ts b/server/graphql/v2/query/collection/ActivitiesCollectionQuery.ts index 76625d58c48..4c0d0a9d279 100644 --- a/server/graphql/v2/query/collection/ActivitiesCollectionQuery.ts +++ b/server/graphql/v2/query/collection/ActivitiesCollectionQuery.ts @@ -24,12 +24,12 @@ import { CHRONOLOGICAL_ORDER_INPUT_DEFAULT_VALUE, GraphQLChronologicalOrderInput, } from '../../input/ChronologicalOrderInput'; -import { CollectionArgs, CollectionReturnType } from '../../interface/Collection'; +import { CollectionArgs, CollectionReturnType, collectionLimitArg } from '../../interface/Collection'; const IGNORED_ACTIVITIES: string[] = [ActivityTypes.COLLECTIVE_TRANSACTION_CREATED]; // This activity is creating a lot of noise, is usually covered already by orders/expenses activities and is not properly categorized (see https://github.com/opencollective/opencollective/issues/5903) const ActivitiesCollectionArgs = { - limit: { ...CollectionArgs.limit, defaultValue: 100 }, + limit: collectionLimitArg(100), offset: CollectionArgs.offset, individual: { type: GraphQLAccountReferenceInput, diff --git a/server/graphql/v2/query/collection/CommunityQuery.ts b/server/graphql/v2/query/collection/CommunityQuery.ts index e5055f4721e..d16c4419799 100644 --- a/server/graphql/v2/query/collection/CommunityQuery.ts +++ b/server/graphql/v2/query/collection/CommunityQuery.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import type Express from 'express'; -import { GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLString } from 'graphql'; +import { GraphQLList, GraphQLNonNull, GraphQLString } from 'graphql'; import { sql } from 'kysely'; import { isNil } from 'lodash'; @@ -18,6 +18,7 @@ import { GraphQLCommunityRelationType } from '../../enum/CommunityRelationType'; import { fetchAccountWithReference, GraphQLAccountReferenceInput } from '../../input/AccountReferenceInput'; import { getAmountRangeQuery, GraphQLAmountRangeInput } from '../../input/AmountRangeInput'; import { GraphQLOrderByInput } from '../../input/OrderByInput'; +import { collectionLimitArg, collectionOffsetArg } from '../../interface/Collection'; const DEFAULT_LIMIT = 100; @@ -255,8 +256,8 @@ const CommunityQuery = { type: GraphQLAmountRangeInput, description: 'Only return accounts that expended within this amount range', }, - limit: { type: new GraphQLNonNull(GraphQLInt), defaultValue: DEFAULT_LIMIT }, - offset: { type: new GraphQLNonNull(GraphQLInt), defaultValue: 0 }, + limit: collectionLimitArg(DEFAULT_LIMIT), + offset: collectionOffsetArg(0), }, async resolve(_: void, args, req: Express.Request) { enforceScope(req, 'host'); diff --git a/server/graphql/v2/query/collection/OrdersCollectionQuery.ts b/server/graphql/v2/query/collection/OrdersCollectionQuery.ts index 85aa3d6c3ea..4b21b59218c 100644 --- a/server/graphql/v2/query/collection/OrdersCollectionQuery.ts +++ b/server/graphql/v2/query/collection/OrdersCollectionQuery.ts @@ -52,7 +52,7 @@ import { GraphQLPaymentMethodReferenceInput, } from '../../input/PaymentMethodReferenceInput'; import { getDatabaseIdFromTierReference, GraphQLTierReferenceInput } from '../../input/TierReferenceInput'; -import { CollectionArgs, CollectionReturnType } from '../../interface/Collection'; +import { CollectionArgs, CollectionReturnType, collectionLimitArg } from '../../interface/Collection'; import { UncategorizedValue } from '../../object/AccountingCategory'; /** @@ -153,7 +153,7 @@ const getCollectivesCondition = ( }; export const OrdersCollectionArgs = { - limit: { ...CollectionArgs.limit, defaultValue: 100 }, + limit: collectionLimitArg(100), offset: CollectionArgs.offset, accountingCategory: { type: new GraphQLList(GraphQLString), diff --git a/server/graphql/v2/query/collection/TransactionGroupCollectionQuery.ts b/server/graphql/v2/query/collection/TransactionGroupCollectionQuery.ts index d5dc9298dfd..2066ad30a22 100644 --- a/server/graphql/v2/query/collection/TransactionGroupCollectionQuery.ts +++ b/server/graphql/v2/query/collection/TransactionGroupCollectionQuery.ts @@ -13,12 +13,12 @@ import { GraphQLTransactionGroupCollection } from '../../collection/TransactionG import { GraphQLTransactionKind } from '../../enum/TransactionKind'; import { GraphQLTransactionType } from '../../enum/TransactionType'; import { fetchAccountWithReference, GraphQLAccountReferenceInput } from '../../input/AccountReferenceInput'; -import { CollectionArgs, CollectionReturnType } from '../../interface/Collection'; +import { CollectionArgs, CollectionReturnType, collectionLimitArg } from '../../interface/Collection'; import { getTransactionKindPriorityCase } from './TransactionsCollectionQuery'; export const TransactionGroupCollectionArgs = { - limit: { ...CollectionArgs.limit, defaultValue: 100 }, + limit: collectionLimitArg(100), offset: CollectionArgs.offset, type: { type: GraphQLTransactionType, diff --git a/server/graphql/v2/query/collection/TransactionsCollectionQuery.ts b/server/graphql/v2/query/collection/TransactionsCollectionQuery.ts index efe79e7641d..ebbc834106c 100644 --- a/server/graphql/v2/query/collection/TransactionsCollectionQuery.ts +++ b/server/graphql/v2/query/collection/TransactionsCollectionQuery.ts @@ -57,7 +57,7 @@ import { GraphQLPayoutMethodReferenceInput, } from '../../input/PayoutMethodReferenceInput'; import { GraphQLVirtualCardReferenceInput } from '../../input/VirtualCardReferenceInput'; -import { CollectionArgs } from '../../interface/Collection'; +import { CollectionArgs, collectionLimitArg } from '../../interface/Collection'; const oneDayInSeconds = 60 * 60 * 24; @@ -84,7 +84,7 @@ export const getTransactionKindPriorityCase = tableName => ` END`; export const TransactionsCollectionArgs = { - limit: { ...CollectionArgs.limit, defaultValue: 100 }, + limit: collectionLimitArg(100), offset: CollectionArgs.offset, type: { type: GraphQLTransactionType, diff --git a/test/server/graphql/v2/mutation/AccountMutations.test.ts b/test/server/graphql/v2/mutation/AccountMutations.test.ts index f4a82584b17..b4a07979995 100644 --- a/test/server/graphql/v2/mutation/AccountMutations.test.ts +++ b/test/server/graphql/v2/mutation/AccountMutations.test.ts @@ -256,7 +256,7 @@ describe('server/graphql/v2/mutation/AccountMutations', () => { ); expect(result.errors).to.exist; - expect(result.errors[0].message).to.match(/Variable "\$key" got invalid value "anInvalidKey\!"/); + expect(result.errors[0].message).to.match(/Variable "\$key" has invalid value.*anInvalidKey!/); }); it('can set nested values', async () => { diff --git a/test/server/graphql/v2/mutation/HostApplicationMutations.test.ts b/test/server/graphql/v2/mutation/HostApplicationMutations.test.ts index b8efee5e34e..c5186f11cc5 100644 --- a/test/server/graphql/v2/mutation/HostApplicationMutations.test.ts +++ b/test/server/graphql/v2/mutation/HostApplicationMutations.test.ts @@ -818,7 +818,7 @@ describe('server/graphql/v2/mutation/HostApplicationMutations', () => { const result = await graphqlQueryV2(REMOVE_HOST_MUTATION, {}); expect(result.errors).to.exist; expect(result.errors[0].message).to.equal( - 'Variable "$account" of required type "AccountReferenceInput!" was not provided.', + 'Variable "$account" has invalid value: Expected a value of non-null type "AccountReferenceInput!" to be provided.', ); }); diff --git a/test/server/graphql/v2/mutation/RootMutations.test.ts b/test/server/graphql/v2/mutation/RootMutations.test.ts index b683dade8db..e2969e2d1c1 100644 --- a/test/server/graphql/v2/mutation/RootMutations.test.ts +++ b/test/server/graphql/v2/mutation/RootMutations.test.ts @@ -88,7 +88,7 @@ describe('server/graphql/v2/mutation/RootMutations', () => { const result = await callMoveExpenseMutation({ expenses: [] }, rootUser); expect(result.errors).to.exist; expect(result.errors[0].message).to.equal( - 'Variable "$destinationAccount" of required type "AccountReferenceInput!" was not provided.', + 'Variable "$destinationAccount" has invalid value: Expected a value of non-null type "AccountReferenceInput!" to be provided.', ); }); diff --git a/test/server/graphql/v2/query/collection/ExpensesCollectionQuery.test.ts b/test/server/graphql/v2/query/collection/ExpensesCollectionQuery.test.ts index 6b62c4a2146..cd4ce03c471 100644 --- a/test/server/graphql/v2/query/collection/ExpensesCollectionQuery.test.ts +++ b/test/server/graphql/v2/query/collection/ExpensesCollectionQuery.test.ts @@ -1404,7 +1404,7 @@ describe('server/graphql/v2/collection/ExpenseCollection', () => { const result = await graphqlQueryV2(expensesQuery, queryParams); expect(result.errors).to.exist; // GraphQL validation error happens before resolver - expect(result.errors[0].message).to.include('Variable "$activity" got invalid value'); + expect(result.errors[0].message).to.include('Variable "$activity" has invalid value'); }); it('Throws error when multiple invalid activity types are provided', async () => { @@ -1415,7 +1415,7 @@ describe('server/graphql/v2/collection/ExpenseCollection', () => { const result = await graphqlQueryV2(expensesQuery, queryParams); expect(result.errors).to.exist; // GraphQL validation error happens before resolver - expect(result.errors[0].message).to.include('Variable "$activity" got invalid value'); + expect(result.errors[0].message).to.include('Variable "$activity" has invalid value'); }); it('Throws error when activity type is not an expense-related activity', async () => { diff --git a/test/server/paymentProviders/opencollective/giftcard.test.js b/test/server/paymentProviders/opencollective/giftcard.test.js index a2520263bda..7f3adae730a 100644 --- a/test/server/paymentProviders/opencollective/giftcard.test.js +++ b/test/server/paymentProviders/opencollective/giftcard.test.js @@ -783,7 +783,9 @@ describe('server/paymentProviders/opencollective/giftcard', () => { // call graphql mutation const gqlResult = await utils.graphqlQuery(createGiftCardsMutation, args, user1); expect(gqlResult.errors[0]).to.exist; - expect(gqlResult.errors[0].toString()).to.contain('"$currency" of required type "String!" was not provided.'); + expect(gqlResult.errors[0].message).to.contain( + 'Variable "$currency" has invalid value: Expected a value of non-null type "String!" to be provided.', + ); }); /** End of "should fail creating a gift card because there is no currency defined" */ it('should fail creating a gift card because there is no amount or monthlyLimitPerMember defined', async () => {