diff --git a/apps/admin-panel/generated.ts b/apps/admin-panel/generated.ts index 6377c919f8..e96b6e50d0 100644 --- a/apps/admin-panel/generated.ts +++ b/apps/admin-panel/generated.ts @@ -65,6 +65,17 @@ export type AccountDetailPayload = { readonly errors: ReadonlyArray; }; +export type AccountForceDeleteInput = { + readonly accountId: Scalars['AccountId']['input']; + readonly cancelIfPositiveBalance?: InputMaybe; +}; + +export type AccountForceDeletePayload = { + readonly __typename: 'AccountForceDeletePayload'; + readonly errors: ReadonlyArray; + readonly success: Scalars['Boolean']['output']; +}; + export const AccountLevel = { One: 'ONE', Three: 'THREE', @@ -393,6 +404,7 @@ export type MerchantPayload = { export type Mutation = { readonly __typename: 'Mutation'; + readonly accountForceDelete: AccountForceDeletePayload; readonly accountUpdateLevel: AccountDetailPayload; readonly accountUpdateStatus: AccountDetailPayload; readonly marketingNotificationTrigger: SuccessPayload; @@ -403,6 +415,11 @@ export type Mutation = { }; +export type MutationAccountForceDeleteArgs = { + input: AccountForceDeleteInput; +}; + + export type MutationAccountUpdateLevelArgs = { input: AccountUpdateLevelInput; }; diff --git a/bats/admin-gql/account-force-delete.gql b/bats/admin-gql/account-force-delete.gql new file mode 100644 index 0000000000..354d8a07e2 --- /dev/null +++ b/bats/admin-gql/account-force-delete.gql @@ -0,0 +1,8 @@ +mutation accountForceDelete($input: AccountForceDeleteInput!) { + accountForceDelete(input: $input) { + errors { + message + } + success + } +} diff --git a/bats/core/api/admin.bats b/bats/core/api/admin.bats index 48b657a746..d4592d77e5 100644 --- a/bats/core/api/admin.bats +++ b/bats/core/api/admin.bats @@ -300,6 +300,24 @@ getEmailCode() { [[ "$num_errors" == "0" && "$success" == "true" ]] || exit 1 } +@test "admin: can force delete account" { + admin_token="$(read_value 'admin.token')" + + create_user 'tester_delete' + id="$(read_value 'tester_delete.account_id')" + + variables=$( + jq -n \ + --arg accountId "$id" \ + '{input: {accountId: $accountId}}' + ) + exec_admin_graphql "$admin_token" 'account-force-delete' "$variables" + num_errors="$(graphql_output '.data.accountForceDelete.errors | length')" + success="$(graphql_output '.data.accountForceDelete.success')" + + [[ "$num_errors" == "0" && "$success" == "true" ]] || exit 1 +} + # TODO: add check by email # TODO: business update map info diff --git a/core/api/src/app/accounts/mark-account-for-deletion.ts b/core/api/src/app/accounts/mark-account-for-deletion.ts index 421192336f..80d7e6c6e4 100644 --- a/core/api/src/app/accounts/mark-account-for-deletion.ts +++ b/core/api/src/app/accounts/mark-account-for-deletion.ts @@ -19,10 +19,12 @@ export const markAccountForDeletion = async ({ accountId, cancelIfPositiveBalance = false, updatedByPrivilegedClientId, + bypassMaxDeletions = false, }: { accountId: AccountId cancelIfPositiveBalance?: boolean updatedByPrivilegedClientId?: PrivilegedClientId + bypassMaxDeletions?: boolean }): Promise => { const accountsRepo = AccountsRepository() const account = await accountsRepo.findById(accountId) @@ -59,7 +61,7 @@ export const markAccountForDeletion = async ({ if (user.deletedPhones) { deletedPhones.push(...user.deletedPhones) } - if (deletedPhones.length > 0) { + if (deletedPhones.length > 0 && !bypassMaxDeletions) { const usersByPhones = await usersRepo.findByDeletedPhones(deletedPhones) if (usersByPhones instanceof Error) return usersByPhones if (usersByPhones.length >= maxDeletions) return new InvalidAccountForDeletionError() diff --git a/core/api/src/app/admin/update-user-email.ts b/core/api/src/app/admin/update-user-email.ts index 8d8cd495b1..89b2e7ca6a 100644 --- a/core/api/src/app/admin/update-user-email.ts +++ b/core/api/src/app/admin/update-user-email.ts @@ -42,6 +42,7 @@ export const updateUserEmail = async ({ const result = await markAccountForDeletion({ accountId: newAccount.id, cancelIfPositiveBalance: true, + bypassMaxDeletions: true, updatedByPrivilegedClientId, }) if (result instanceof Error) return result diff --git a/core/api/src/app/admin/update-user-phone.ts b/core/api/src/app/admin/update-user-phone.ts index 88b54d358a..999606dab8 100644 --- a/core/api/src/app/admin/update-user-phone.ts +++ b/core/api/src/app/admin/update-user-phone.ts @@ -38,6 +38,7 @@ export const updateUserPhone = async ({ const result = await markAccountForDeletion({ accountId: newAccount.id, cancelIfPositiveBalance: true, + bypassMaxDeletions: true, updatedByPrivilegedClientId, }) if (result instanceof Error) return result diff --git a/core/api/src/debug/force-delete-account.ts b/core/api/src/debug/force-delete-account.ts new file mode 100644 index 0000000000..e8e2c6d6cb --- /dev/null +++ b/core/api/src/debug/force-delete-account.ts @@ -0,0 +1,36 @@ +/** + * how to run: + * + * pnpm tsx src/debug/force-delete-account.ts + * + * : ID of the account to force delete (bypasses max deletions limit) + */ + +import { Accounts } from "@/app" + +import { setupMongoConnection } from "@/services/mongodb" + +const main = async () => { + const args = process.argv.slice(-1) + const accountId = args[0] as AccountId + + const result = await Accounts.markAccountForDeletion({ + accountId, + cancelIfPositiveBalance: true, + bypassMaxDeletions: true, + updatedByPrivilegedClientId: "admin" as PrivilegedClientId, + }) + + if (result instanceof Error) { + console.error("Error:", result) + return + } + console.log(`Successfully force deleted account ${accountId}`) +} + +setupMongoConnection() + .then(async (mongoose) => { + await main() + if (mongoose) await mongoose.connection.close() + }) + .catch((err) => console.log(err)) diff --git a/core/api/src/graphql/admin/mutations.ts b/core/api/src/graphql/admin/mutations.ts index bcf7945e14..24348556ef 100644 --- a/core/api/src/graphql/admin/mutations.ts +++ b/core/api/src/graphql/admin/mutations.ts @@ -6,6 +6,7 @@ import MerchantMapValidateMutation from "./root/mutation/merchant-map-validate" import AccountUpdateLevelMutation from "./root/mutation/account-update-level" import AccountUpdateStatusMutation from "./root/mutation/account-update-status" +import AccountForceDeleteMutation from "./root/mutation/account-force-delete" import TriggerMarketingNotificationMutation from "./root/mutation/marketing-notification-trigger" @@ -18,6 +19,7 @@ export const mutationFields = { userUpdateEmail: UserUpdateEmailMutation, accountUpdateLevel: AccountUpdateLevelMutation, accountUpdateStatus: AccountUpdateStatusMutation, + accountForceDelete: AccountForceDeleteMutation, merchantMapValidate: MerchantMapValidateMutation, merchantMapDelete: MerchantMapDeleteMutation, marketingNotificationTrigger: TriggerMarketingNotificationMutation, diff --git a/core/api/src/graphql/admin/root/mutation/account-force-delete.ts b/core/api/src/graphql/admin/root/mutation/account-force-delete.ts new file mode 100644 index 0000000000..012e5c61ba --- /dev/null +++ b/core/api/src/graphql/admin/root/mutation/account-force-delete.ts @@ -0,0 +1,59 @@ +import { Accounts } from "@/app" + +import { GT } from "@/graphql/index" +import AccountId from "@/graphql/shared/types/scalar/account-id" +import { mapAndParseErrorForGqlResponse } from "@/graphql/error-map" +import AccountForceDeletePayload from "@/graphql/admin/types/payload/account-force-delete" + +const AccountForceDeleteInput = GT.Input({ + name: "AccountForceDeleteInput", + fields: () => ({ + accountId: { + type: GT.NonNull(AccountId), + }, + cancelIfPositiveBalance: { + type: GT.Boolean, + defaultValue: true, + }, + }), +}) + +const AccountForceDeleteMutation = GT.Field< + null, + GraphQLAdminContext, + { + input: { + accountId: AccountId | Error + cancelIfPositiveBalance?: boolean + } + } +>({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(AccountForceDeletePayload), + args: { + input: { type: GT.NonNull(AccountForceDeleteInput) }, + }, + resolve: async (_, args, { privilegedClientId }) => { + const { accountId, cancelIfPositiveBalance = true } = args.input + + if (accountId instanceof Error) + return { errors: [{ message: accountId.message }], success: false } + + const result = await Accounts.markAccountForDeletion({ + accountId, + cancelIfPositiveBalance, + bypassMaxDeletions: true, + updatedByPrivilegedClientId: privilegedClientId, + }) + + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)], success: false } + } + + return { errors: [], success: true } + }, +}) + +export default AccountForceDeleteMutation diff --git a/core/api/src/graphql/admin/schema.graphql b/core/api/src/graphql/admin/schema.graphql index 55046f0795..7f9a020e2f 100644 --- a/core/api/src/graphql/admin/schema.graphql +++ b/core/api/src/graphql/admin/schema.graphql @@ -3,6 +3,16 @@ type AccountDetailPayload { errors: [Error!]! } +input AccountForceDeleteInput { + accountId: AccountId! + cancelIfPositiveBalance: Boolean = true +} + +type AccountForceDeletePayload { + errors: [Error!]! + success: Boolean! +} + """Unique identifier of an account""" scalar AccountId @@ -345,6 +355,7 @@ type MerchantPayload { } type Mutation { + accountForceDelete(input: AccountForceDeleteInput!): AccountForceDeletePayload! accountUpdateLevel(input: AccountUpdateLevelInput!): AccountDetailPayload! accountUpdateStatus(input: AccountUpdateStatusInput!): AccountDetailPayload! marketingNotificationTrigger(input: MarketingNotificationTriggerInput!): SuccessPayload! diff --git a/core/api/src/graphql/admin/types/payload/account-force-delete.ts b/core/api/src/graphql/admin/types/payload/account-force-delete.ts new file mode 100644 index 0000000000..4788a686f5 --- /dev/null +++ b/core/api/src/graphql/admin/types/payload/account-force-delete.ts @@ -0,0 +1,16 @@ +import { GT } from "@/graphql/index" +import IError from "@/graphql/shared/types/abstract/error" + +const AccountForceDeletePayload = GT.Object({ + name: "AccountForceDeletePayload", + fields: () => ({ + errors: { + type: GT.NonNullList(IError), + }, + success: { + type: GT.NonNull(GT.Boolean), + }, + }), +}) + +export default AccountForceDeletePayload