diff --git a/apple/OmnivoreKit/Sources/App/Views/DebugMenuView.swift b/apple/OmnivoreKit/Sources/App/Views/DebugMenuView.swift index 6b6f83eae..abff9974b 100644 --- a/apple/OmnivoreKit/Sources/App/Views/DebugMenuView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/DebugMenuView.swift @@ -26,7 +26,7 @@ struct DebugMenuView: View { Button( action: { - authenticator.logout() + authenticator.logout(dataService: dataService) dataService.switchAppEnvironment(appEnvironment: selectedEnvironment) }, label: { Text("Apply Changes") } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ManageAccountView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ManageAccountView.swift new file mode 100644 index 000000000..940c5059c --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ManageAccountView.swift @@ -0,0 +1,61 @@ +import Services +import SwiftUI +import Views + +struct ManageAccountView: View { + @EnvironmentObject var authenticator: Authenticator + @EnvironmentObject var dataService: DataService + + @State private var showDeleteAccountConfirmation = false + @StateObject private var viewModel = ProfileContainerViewModel() + + var body: some View { + #if os(iOS) + Form { + innerBody + } + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + + var innerBody: some View { + Group { + Section { + ProfileCard(data: viewModel.profileCardData) + .task { + await viewModel.loadProfileData(dataService: dataService) + } + } + Section { + Button( + action: { + showDeleteAccountConfirmation = true + }, + label: { Text("Delete Account") } + ) + .alert(isPresented: $showDeleteAccountConfirmation) { + Alert( + title: Text("Are you sure you want to delete your account? This action can't be undone."), + primaryButton: .destructive(Text("Delete Account")) { + Task { + await viewModel.deleteAccount(dataService: dataService, authenticator: authenticator) + } + }, + secondaryButton: .cancel() + ) + } + } + + if let errorMessage = viewModel.deleteAccountErrorMessage { + Text(errorMessage) + .font(.appBody) + .foregroundColor(.red) + .multilineTextAlignment(.leading) + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index 2d4eef29d..8a5c1688c 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -7,6 +7,7 @@ import Views @MainActor final class ProfileContainerViewModel: ObservableObject { @Published var isLoading = false @Published var profileCardData = ProfileCardData() + @Published var deleteAccountErrorMessage: String? var appVersionString: String { if let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String { @@ -31,6 +32,20 @@ import Views } } + func deleteAccount(dataService: DataService, authenticator: Authenticator) async { + guard let currentViewer = dataService.currentViewer else { + deleteAccountErrorMessage = "Unable to load account information." + return + } + + do { + try await dataService.deleteAccount(userID: currentViewer.unwrappedUserID) + authenticator.logout(dataService: dataService) + } catch { + deleteAccountErrorMessage = "We were unable to delete your account." + } + } + private func loadProfileCardData(viewer: Viewer) { profileCardData = ProfileCardData( name: viewer.unwrappedName, @@ -106,14 +121,10 @@ struct ProfileView: View { } Section(footer: Text(viewModel.appVersionString)) { - if FeatureFlag.showAccountDeletion { - NavigationLink( - destination: ManageAccountView(handleAccountDeletion: { - print("delete account") - }) - ) { - Text("Manage Account") - } + NavigationLink( + destination: ManageAccountView() + ) { + Text("Manage Account") } Text("Logout") @@ -124,7 +135,7 @@ struct ProfileView: View { Alert( title: Text("Are you sure you want to logout?"), primaryButton: .destructive(Text("Confirm")) { - authenticator.logout() + authenticator.logout(dataService: dataService) }, secondaryButton: .cancel() ) diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift index 9acf9a501..033f6f5e4 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift @@ -27,7 +27,7 @@ public final class RootViewModel: ObservableObject { #if DEBUG if CommandLine.arguments.contains("--uitesting") { - services.authenticator.logout() + services.authenticator.logout(dataService: services.dataService) } #endif } diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift b/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift index cd2f0c66b..432508e68 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift @@ -36,7 +36,8 @@ public final class Authenticator: ObservableObject { ValetKey.authToken.value() } - public func logout() { + public func logout(dataService: DataService) { + dataService.resetCoreData() clearCreds() Authenticator.unregisterIntercomUser?() isLoggedIn = false diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index 078a6ffea..483cb3521 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -95,7 +95,7 @@ public final class DataService: ObservableObject { } } - private func resetCoreData() { + func resetCoreData() { clearCoreData() persistentContainer = PersistentContainer.make() diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index d0242d354..d06651a19 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -3382,6 +3382,136 @@ extension Selection where TypeLock == Never, Type == Never { typealias CreateReminderSuccess = Selection } +extension Objects { + struct DeleteAccountError { + let __typename: TypeName = .deleteAccountError + let errorCodes: [String: [Enums.DeleteAccountErrorCode]] + + enum TypeName: String, Codable { + case deleteAccountError = "DeleteAccountError" + } + } +} + +extension Objects.DeleteAccountError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DeleteAccountErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.DeleteAccountError { + func errorCodes() throws -> [Enums.DeleteAccountErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteAccountError = Selection +} + +extension Objects { + struct DeleteAccountSuccess { + let __typename: TypeName = .deleteAccountSuccess + let userId: [String: String] + + enum TypeName: String, Codable { + case deleteAccountSuccess = "DeleteAccountSuccess" + } + } +} + +extension Objects.DeleteAccountSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "userId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + userId = map["userId"] + } +} + +extension Fields where TypeLock == Objects.DeleteAccountSuccess { + func userId() throws -> String { + let field = GraphQLField.leaf( + name: "userID", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.userId[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteAccountSuccess = Selection +} + extension Objects { struct DeleteHighlightError { let __typename: TypeName = .deleteHighlightError @@ -7383,6 +7513,7 @@ extension Objects { let createNewsletterEmail: [String: Unions.CreateNewsletterEmailResult] let createReaction: [String: Unions.CreateReactionResult] let createReminder: [String: Unions.CreateReminderResult] + let deleteAccount: [String: Unions.DeleteAccountResult] let deleteHighlight: [String: Unions.DeleteHighlightResult] let deleteHighlightReply: [String: Unions.DeleteHighlightReplyResult] let deleteLabel: [String: Unions.DeleteLabelResult] @@ -7480,6 +7611,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.CreateReminderResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "deleteAccount": + if let value = try container.decode(Unions.DeleteAccountResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "deleteHighlight": if let value = try container.decode(Unions.DeleteHighlightResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -7667,6 +7802,7 @@ extension Objects.Mutation: Decodable { createNewsletterEmail = map["createNewsletterEmail"] createReaction = map["createReaction"] createReminder = map["createReminder"] + deleteAccount = map["deleteAccount"] deleteHighlight = map["deleteHighlight"] deleteHighlightReply = map["deleteHighlightReply"] deleteLabel = map["deleteLabel"] @@ -7884,6 +8020,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func deleteAccount(userId: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "deleteAccount", + arguments: [Argument(name: "userID", type: "ID!", value: userId)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.deleteAccount[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func deleteHighlight(highlightId: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "deleteHighlight", @@ -18211,6 +18366,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias CreateReminderResult = Selection } +extension Unions { + struct DeleteAccountResult { + let __typename: TypeName + let errorCodes: [String: [Enums.DeleteAccountErrorCode]] + let userId: [String: String] + + enum TypeName: String, Codable { + case deleteAccountError = "DeleteAccountError" + case deleteAccountSuccess = "DeleteAccountSuccess" + } + } +} + +extension Unions.DeleteAccountResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DeleteAccountErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "userId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + userId = map["userId"] + } +} + +extension Fields where TypeLock == Unions.DeleteAccountResult { + func on(deleteAccountError: Selection, deleteAccountSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "DeleteAccountError", selection: deleteAccountError.selection), GraphQLField.fragment(type: "DeleteAccountSuccess", selection: deleteAccountSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .deleteAccountError: + let data = Objects.DeleteAccountError(errorCodes: data.errorCodes) + return try deleteAccountError.decode(data: data) + case .deleteAccountSuccess: + let data = Objects.DeleteAccountSuccess(userId: data.userId) + return try deleteAccountSuccess.decode(data: data) + } + case .mocking: + return deleteAccountError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteAccountResult = Selection +} + extension Unions { struct DeleteHighlightReplyResult { let __typename: TypeName @@ -22234,6 +22463,17 @@ extension Enums { } } +extension Enums { + /// DeleteAccountErrorCode + enum DeleteAccountErrorCode: String, CaseIterable, Codable { + case forbidden = "FORBIDDEN" + + case unauthorized = "UNAUTHORIZED" + + case userNotFound = "USER_NOT_FOUND" + } +} + extension Enums { /// DeleteHighlightErrorCode enum DeleteHighlightErrorCode: String, CaseIterable, Codable { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/DeleteAccount.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/DeleteAccount.swift new file mode 100644 index 000000000..197de9219 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/DeleteAccount.swift @@ -0,0 +1,46 @@ +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func deleteAccount(userID: String) async throws { + enum MutationResult { + case success(id: String) + case error(errorMessage: String) + } + + let selection = Selection { + try $0.on( + deleteAccountError: .init { + .error(errorMessage: (try $0.errorCodes().first ?? .forbidden).rawValue) + }, + deleteAccountSuccess: .init { + .success(id: try $0.userId()) + } + ) + } + + let mutation = Selection.Mutation { + try $0.deleteAccount(userId: userID, selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { mutationResult in + guard let payload = try? mutationResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "failed to delete user")) + return + } + + switch payload.data { + case .success: + continuation.resume() + case .error: + continuation.resume(throwing: BasicError.message(messageText: "failed to delete user")) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift index 5642e89eb..5c0aab9af 100644 --- a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift +++ b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift @@ -7,7 +7,6 @@ import Foundation #endif public enum FeatureFlag { - public static let showAccountDeletion = false public static let enableSnoozeFromShareExtension = false public static let enableRemindersFromShareExtension = false public static let enableReadNow = false diff --git a/apple/OmnivoreKit/Sources/Views/UserSettings/ManageAccountView.swift b/apple/OmnivoreKit/Sources/Views/UserSettings/ManageAccountView.swift deleted file mode 100644 index 1b6d589ca..000000000 --- a/apple/OmnivoreKit/Sources/Views/UserSettings/ManageAccountView.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SwiftUI - -public struct ManageAccountView: View { - let handleAccountDeletion: () -> Void - @State private var showDeleteAccountConfirmation = false - - public init(handleAccountDeletion: @escaping () -> Void) { - self.handleAccountDeletion = handleAccountDeletion - } - - public var body: some View { - Button( - action: { - showDeleteAccountConfirmation = true - }, - label: { Text("Delete my account") } - ) - .alert(isPresented: $showDeleteAccountConfirmation) { - Alert( - title: Text("Are you sure you want to delete your account? This action can't be undone."), - primaryButton: .destructive(Text("Delete Account")) { - handleAccountDeletion() - }, - secondaryButton: .cancel() - ) - } - } -} diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 341803d46..11500a2eb 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -32,7 +32,7 @@ services: - http.cors.allow-credentials=true - http.port=9201 volumes: - - ./.docker/elastic-test-data:/usr/share/elasticsearch-test/data + - ./.docker/elastic-test-data:/usr/share/elasticsearch/data ports: - "9201:9201" diff --git a/packages/api/src/datalayer/user/index.ts b/packages/api/src/datalayer/user/index.ts index 16bee8312..a4f6058b2 100644 --- a/packages/api/src/datalayer/user/index.ts +++ b/packages/api/src/datalayer/user/index.ts @@ -10,7 +10,7 @@ import { UpdateSet, UserData, } from './model' -import DataModel, { MAX_RECORDS_LIMIT } from '../model' +import DataModel, { DataModelError, MAX_RECORDS_LIMIT } from '../model' import Knex from 'knex' import { ENABLE_DB_REQUEST_LOGGING, globalCounter, logMethod } from '../helpers' import { Table } from '../../utils/dictionary' @@ -327,6 +327,18 @@ class UserModel extends DataModel { } return this.kx.transaction((tx) => this.updateProfile(userId, set, tx)) } + + @logMethod + async delete( + userId: string, + tx?: Knex.Transaction + ): Promise { + if (tx) { + return super.delete(userId, tx) + } + + return this.kx.transaction((tx) => super.delete(userId, tx)) + } } export default UserModel diff --git a/packages/api/src/elastic/pages.ts b/packages/api/src/elastic/pages.ts index 64e86d63c..2b53c9509 100644 --- a/packages/api/src/elastic/pages.ts +++ b/packages/api/src/elastic/pages.ts @@ -309,7 +309,7 @@ export const getPageByParam = async ( id: body.hits.hits[0]._id, } as Page } catch (e) { - console.error('failed to get pages by param in elastic', e) + console.error('failed to get page by param in elastic', e) return undefined } } @@ -509,3 +509,41 @@ export const countByCreatedAt = async ( return 0 } } + +export const deletePagesByParam = async ( + param: Record, + ctx: PageContext +): Promise => { + try { + const params = { + query: { + bool: { + filter: Object.keys(param).map((key) => { + return { + term: { + [key]: param[key as K], + }, + } + }), + }, + }, + } + + const { body } = await client.deleteByQuery({ + index: INDEX_ALIAS, + body: params, + }) + + if (body.deleted > 0) { + // * means deleting all pages of the same user + await ctx.pubsub.entityDeleted(EntityType.PAGE, '*', ctx.uid) + + return true + } + + return false + } catch (e) { + console.error('failed to delete pages by param in elastic', e) + return false + } +} diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index c6ef314ee..5edea20f8 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -424,6 +424,24 @@ export type CreateReminderSuccess = { reminder: Reminder; }; +export type DeleteAccountError = { + __typename?: 'DeleteAccountError'; + errorCodes: Array; +}; + +export enum DeleteAccountErrorCode { + Forbidden = 'FORBIDDEN', + Unauthorized = 'UNAUTHORIZED', + UserNotFound = 'USER_NOT_FOUND' +} + +export type DeleteAccountResult = DeleteAccountError | DeleteAccountSuccess; + +export type DeleteAccountSuccess = { + __typename?: 'DeleteAccountSuccess'; + userID: Scalars['ID']; +}; + export type DeleteHighlightError = { __typename?: 'DeleteHighlightError'; errorCodes: Array; @@ -863,6 +881,7 @@ export type Mutation = { createNewsletterEmail: CreateNewsletterEmailResult; createReaction: CreateReactionResult; createReminder: CreateReminderResult; + deleteAccount: DeleteAccountResult; deleteHighlight: DeleteHighlightResult; deleteHighlightReply: DeleteHighlightReplyResult; deleteLabel: DeleteLabelResult; @@ -948,6 +967,11 @@ export type MutationCreateReminderArgs = { }; +export type MutationDeleteAccountArgs = { + userID: Scalars['ID']; +}; + + export type MutationDeleteHighlightArgs = { highlightId: Scalars['ID']; }; @@ -2437,6 +2461,10 @@ export type ResolversTypes = { CreateReminderResult: ResolversTypes['CreateReminderError'] | ResolversTypes['CreateReminderSuccess']; CreateReminderSuccess: ResolverTypeWrapper; Date: ResolverTypeWrapper; + DeleteAccountError: ResolverTypeWrapper; + DeleteAccountErrorCode: DeleteAccountErrorCode; + DeleteAccountResult: ResolversTypes['DeleteAccountError'] | ResolversTypes['DeleteAccountSuccess']; + DeleteAccountSuccess: ResolverTypeWrapper; DeleteHighlightError: ResolverTypeWrapper; DeleteHighlightErrorCode: DeleteHighlightErrorCode; DeleteHighlightReplyError: ResolverTypeWrapper; @@ -2772,6 +2800,9 @@ export type ResolversParentTypes = { CreateReminderResult: ResolversParentTypes['CreateReminderError'] | ResolversParentTypes['CreateReminderSuccess']; CreateReminderSuccess: CreateReminderSuccess; Date: Scalars['Date']; + DeleteAccountError: DeleteAccountError; + DeleteAccountResult: ResolversParentTypes['DeleteAccountError'] | ResolversParentTypes['DeleteAccountSuccess']; + DeleteAccountSuccess: DeleteAccountSuccess; DeleteHighlightError: DeleteHighlightError; DeleteHighlightReplyError: DeleteHighlightReplyError; DeleteHighlightReplyResult: ResolversParentTypes['DeleteHighlightReplyError'] | ResolversParentTypes['DeleteHighlightReplySuccess']; @@ -3274,6 +3305,20 @@ export interface DateScalarConfig extends GraphQLScalarTypeConfig = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type DeleteAccountResultResolvers = { + __resolveType: TypeResolveFn<'DeleteAccountError' | 'DeleteAccountSuccess', ParentType, ContextType>; +}; + +export type DeleteAccountSuccessResolvers = { + userID?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type DeleteHighlightErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -3617,6 +3662,7 @@ export type MutationResolvers; createReaction?: Resolver>; createReminder?: Resolver>; + deleteAccount?: Resolver>; deleteHighlight?: Resolver>; deleteHighlightReply?: Resolver>; deleteLabel?: Resolver>; @@ -4394,6 +4440,9 @@ export type Resolvers = { CreateReminderResult?: CreateReminderResultResolvers; CreateReminderSuccess?: CreateReminderSuccessResolvers; Date?: GraphQLScalarType; + DeleteAccountError?: DeleteAccountErrorResolvers; + DeleteAccountResult?: DeleteAccountResultResolvers; + DeleteAccountSuccess?: DeleteAccountSuccessResolvers; DeleteHighlightError?: DeleteHighlightErrorResolvers; DeleteHighlightReplyError?: DeleteHighlightReplyErrorResolvers; DeleteHighlightReplyResult?: DeleteHighlightReplyResultResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 38349889d..73e5b5bb2 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -371,6 +371,22 @@ type CreateReminderSuccess { scalar Date +type DeleteAccountError { + errorCodes: [DeleteAccountErrorCode!]! +} + +enum DeleteAccountErrorCode { + FORBIDDEN + UNAUTHORIZED + USER_NOT_FOUND +} + +union DeleteAccountResult = DeleteAccountError | DeleteAccountSuccess + +type DeleteAccountSuccess { + userID: ID! +} + type DeleteHighlightError { errorCodes: [DeleteHighlightErrorCode!]! } @@ -766,6 +782,7 @@ type Mutation { createNewsletterEmail: CreateNewsletterEmailResult! createReaction(input: CreateReactionInput!): CreateReactionResult! createReminder(input: CreateReminderInput!): CreateReminderResult! + deleteAccount(userID: ID!): DeleteAccountResult! deleteHighlight(highlightId: ID!): DeleteHighlightResult! deleteHighlightReply(highlightReplyId: ID!): DeleteHighlightReplyResult! deleteLabel(id: ID!): DeleteLabelResult! diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index 2a1d39641..013cab0d1 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -306,7 +306,7 @@ export const createArticleResolver = authorized< let uploadFileUrlOverride = '' if (uploadFileId) { const uploadFileData = await authTrx(async (tx) => { - return await models.uploadFile.setFileUploadComplete(uploadFileId, tx) + return models.uploadFile.setFileUploadComplete(uploadFileId, tx) }) if (!uploadFileData || !uploadFileData.id || !uploadFileData.fileName) { return pageError( diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index a3e2de75f..f0aea7a44 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -30,6 +30,7 @@ import { createLabelResolver, createNewsletterEmailResolver, createReminderResolver, + deleteAccountResolver, deleteHighlightResolver, deleteLabelResolver, deleteNewsletterEmailResolver, @@ -118,6 +119,7 @@ export const functionResolvers = { googleLogin: googleLoginResolver, googleSignup: googleSignupResolver, logOut: logOutResolver, + deleteAccount: deleteAccountResolver, saveArticleReadingProgress: saveArticleReadingProgressResolver, updateUser: updateUserResolver, updateUserProfile: updateUserProfileResolver, @@ -586,4 +588,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('Webhook'), ...resultResolveTypeResolver('ApiKeys'), ...resultResolveTypeResolver('RevokeApiKey'), + ...resultResolveTypeResolver('DeleteAccount'), } diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index 3c8673c5f..cf57a201a 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -1,9 +1,13 @@ import { + DeleteAccountError, + DeleteAccountErrorCode, + DeleteAccountSuccess, GoogleSignupResult, LoginErrorCode, LoginResult, LogOutErrorCode, LogOutResult, + MutationDeleteAccountArgs, MutationGoogleLoginArgs, MutationGoogleSignupArgs, MutationLoginArgs, @@ -34,6 +38,7 @@ import { validateUsername } from '../../utils/usernamePolicy' import * as jwt from 'jsonwebtoken' import { createUser } from '../../services/create_user' import { comparePassword, hashPassword } from '../../utils/auth' +import { deletePagesByParam } from '../../elastic/pages' export const updateUserResolver = authorized< UpdateUserSuccess, @@ -355,3 +360,45 @@ export const signupResolver: ResolverFn< return { errorCodes: [SignupErrorCode.Unknown] } } } + +export const deleteAccountResolver = authorized< + DeleteAccountSuccess, + DeleteAccountError, + MutationDeleteAccountArgs +>(async (_, { userID }, { models, claims, log, pubsub }) => { + const user = await models.user.get(userID) + if (!user) { + return { + errorCodes: [DeleteAccountErrorCode.UserNotFound], + } + } + + if (user.id !== claims.uid) { + return { + errorCodes: [DeleteAccountErrorCode.Unauthorized], + } + } + + log.info('Deleting a user account', { + userID, + labels: { + source: 'resolver', + resolver: 'deleteAccountResolver', + uid: claims.uid, + }, + }) + + const deletedUser = await models.user.delete(userID) + if ('error' in deletedUser) { + log.error('Error deleting user account', deletedUser.error) + + return { + errorCodes: [DeleteAccountErrorCode.UserNotFound], + } + } + + // delete this user's pages in elastic + await deletePagesByParam({ userId: userID }, { uid: userID, pubsub }) + + return { userID } +}) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index fb8766940..6564ef9c4 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -189,6 +189,22 @@ const schema = gql` message: String } + enum DeleteAccountErrorCode { + USER_NOT_FOUND + UNAUTHORIZED + FORBIDDEN + } + + type DeleteAccountError { + errorCodes: [DeleteAccountErrorCode!]! + } + + type DeleteAccountSuccess { + userID: ID! + } + + union DeleteAccountResult = DeleteAccountSuccess | DeleteAccountError + union UpdateUserResult = UpdateUserSuccess | UpdateUserError input UpdateUserInput { name: String! @sanitize(maxLength: 50) @@ -1752,6 +1768,7 @@ const schema = gql` googleLogin(input: GoogleLoginInput!): LoginResult! googleSignup(input: GoogleSignupInput!): GoogleSignupResult! logOut: LogOutResult! + deleteAccount(userID: ID!): DeleteAccountResult! updateUser(input: UpdateUserInput!): UpdateUserResult! updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResult! createArticle(input: CreateArticleInput!): CreateArticleResult! diff --git a/packages/api/test/elastic/index.test.ts b/packages/api/test/elastic/index.test.ts index 97fba7520..8b6665d02 100644 --- a/packages/api/test/elastic/index.test.ts +++ b/packages/api/test/elastic/index.test.ts @@ -14,6 +14,7 @@ import { countByCreatedAt, createPage, deletePage, + deletePagesByParam, getPageById, getPageByParam, searchPages, @@ -301,4 +302,41 @@ describe('elastic api', () => { expect(result).to.be.true }) }) + + describe('deletePagesByParam', () => { + const userId = 'test user id' + + before(async () => { + // create a testing page + await createPage( + { + content: 'deletePagesByParam content', + createdAt: new Date(), + hash: '', + id: '', + pageType: PageType.Article, + readingProgressAnchorIndex: 0, + readingProgressPercent: 0, + savedAt: new Date(), + slug: 'deletePagesByParam slug', + state: ArticleSavingRequestStatus.Succeeded, + title: 'deletePagesByParam title', + url: 'https://localhost/deletePagesByParam', + userId, + }, + ctx + ) + }) + + it('deletes page by userId', async () => { + const deleted = await deletePagesByParam( + { + userId, + }, + ctx + ) + + expect(deleted).to.be.true + }) + }) }) diff --git a/packages/api/test/resolvers/user_delete_account.test.ts b/packages/api/test/resolvers/user_delete_account.test.ts new file mode 100644 index 000000000..6859217ca --- /dev/null +++ b/packages/api/test/resolvers/user_delete_account.test.ts @@ -0,0 +1,71 @@ +import { createTestUser, deleteTestUser } from '../db' +import { graphqlRequest, request } from '../util' +import * as chai from 'chai' +import { expect } from 'chai' +import 'mocha' +import { User } from '../../src/entity/user' +import chaiString from 'chai-string' +import { DeleteAccountErrorCode } from '../../src/generated/graphql' + +chai.use(chaiString) + +const deleteAccountRequest = async (authToken: string, userId: string) => { + const mutation = ` + mutation { + deleteAccount( + userID: "${userId}", + ) { + ... on DeleteAccountSuccess { + userID + } + ... on DeleteAccountError { + errorCodes + } + } + } + ` + return graphqlRequest(mutation, authToken).expect(200) +} + +describe('the deleteAccount API', () => { + const username = 'newFakeUser' + let authToken: string + let user: User + + before(async () => { + // create test user and login + user = await createTestUser(username) + const res = await request + .post('/local/debug/fake-user-login') + .send({ fakeEmail: user.email }) + + authToken = res.body.authToken + }) + + after(async () => { + await deleteTestUser(username) + }) + + context('deleting a user that exists', () => { + it('should return a unauthorized error if authToken is invalid', async () => { + const res = await deleteAccountRequest('invalid-auth-token', user.id) + expect(res.body.data.deleteAccount.errorCodes).to.contain( + DeleteAccountErrorCode.Unauthorized + ) + }) + + it('should return the user id after a successful user deletion', async () => { + const res = await deleteAccountRequest(authToken, user.id) + expect(res.body.data.deleteAccount.userID).to.eql(user.id) + }) + }) + + context('deleting a user that does not exist', () => { + it('should return a user not found error if user id is invalid', async () => { + const res = await deleteAccountRequest(authToken, 'invalid-user-id') + expect(res.body.data.deleteAccount.errorCodes).to.contain( + DeleteAccountErrorCode.UserNotFound + ) + }) + }) +}) diff --git a/packages/db/migrations/0086.do.grant_delete_on_user_table.sql b/packages/db/migrations/0086.do.grant_delete_on_user_table.sql new file mode 100755 index 000000000..c3d235ced --- /dev/null +++ b/packages/db/migrations/0086.do.grant_delete_on_user_table.sql @@ -0,0 +1,9 @@ +-- Type: DO +-- Name: grant_delete_on_user_table +-- Description: Allows the Omnivore User to delete themselves (for delete account app feature) + +BEGIN; + +GRANT DELETE ON omnivore.user TO omnivore_user; + +COMMIT; diff --git a/packages/db/migrations/0086.undo.grant_delete_on_user_table.sql b/packages/db/migrations/0086.undo.grant_delete_on_user_table.sql new file mode 100755 index 000000000..0dd09b6ff --- /dev/null +++ b/packages/db/migrations/0086.undo.grant_delete_on_user_table.sql @@ -0,0 +1,9 @@ +-- Type: UNDO +-- Name: grant_delete_on_user_table +-- Description: Allows the Omnivore User to delete themselves (for delete account app feature) + +BEGIN; + +-- do nothing here, there's no reason to undo this migration. + +COMMIT;