diff --git a/packages/api/src/resolvers/mutations/index.ts b/packages/api/src/resolvers/mutations/index.ts index 4a846d6295..13386c2be8 100755 --- a/packages/api/src/resolvers/mutations/index.ts +++ b/packages/api/src/resolvers/mutations/index.ts @@ -150,6 +150,7 @@ import prepareUserAvatarUpload from './users/prepareUserAvatarUpload.js'; import rejectOrder from './orders/rejectOrder.js'; import removePushSubscription from './users/removePushSubscription.js'; import addPushSubscription from './users/addPushSubscription.js'; +import removeUserProductReviews from './users/removeUserProductReviews.js'; export default { logout: acl(actions.logout)(logout), @@ -312,4 +313,5 @@ export default { signPaymentProviderForCheckout: acl(actions.registerPaymentCredentials)( signPaymentProviderForCheckout, ), + removeUserProductReviews, }; diff --git a/packages/api/src/resolvers/mutations/users/removeUser.ts b/packages/api/src/resolvers/mutations/users/removeUser.ts index 83b538e70a..445f8dd6ab 100755 --- a/packages/api/src/resolvers/mutations/users/removeUser.ts +++ b/packages/api/src/resolvers/mutations/users/removeUser.ts @@ -4,16 +4,20 @@ import { UserNotFoundError } from '../../../errors.js'; export default async function removeUser( root: never, - params: { userId: string }, - { modules, userId }: Context, + params: { userId: string; removeUserReviews?: boolean }, + unchainedAPI: Context, ) { - const { userId: paramUserId } = params; + const { modules, userId } = unchainedAPI; + const { userId: paramUserId, removeUserReviews = false } = params; const normalizedUserId = paramUserId || userId; log(`mutation removeUser ${normalizedUserId}`, { userId }); if (!(await modules.users.userExists({ userId: normalizedUserId }))) - throw UserNotFoundError({ id: normalizedUserId }); + throw new UserNotFoundError({ userId: normalizedUserId }); - return modules.users.delete(normalizedUserId); + if (removeUserReviews) { + await modules.products.reviews.deleteMany({ authorId: userId }); + } + return modules.users.delete({ userId: normalizedUserId }, unchainedAPI); } diff --git a/packages/api/src/resolvers/mutations/users/removeUserProductReviews.ts b/packages/api/src/resolvers/mutations/users/removeUserProductReviews.ts new file mode 100644 index 0000000000..ce01141772 --- /dev/null +++ b/packages/api/src/resolvers/mutations/users/removeUserProductReviews.ts @@ -0,0 +1,23 @@ +import { log } from '@unchainedshop/logger'; +import { InvalidIdError } from '../../../errors.js'; +import { Context } from '../../../context.js'; + +export default async function removeUserProductReviews( + root: never, + params: { + userId?: string; + }, + { modules, userId: currentUserId }: Context, +) { + const normalizedUserId = params?.userId || currentUserId; + log(`mutation removeUserProductReviews ${normalizedUserId}`, { + userId: currentUserId, + }); + if (!normalizedUserId) throw new InvalidIdError({ userId: normalizedUserId }); + + // Do not check for existance of user as the existance check would return false if the user is in status + // 'deleted' and we still want to remove the reviews in that case + await modules.products.reviews.deleteMany({ authorId: normalizedUserId }); + + return true; +} diff --git a/packages/api/src/resolvers/mutations/users/updateUserProfile.ts b/packages/api/src/resolvers/mutations/users/updateUserProfile.ts index 2ec77ca986..f5db2a2f5c 100755 --- a/packages/api/src/resolvers/mutations/users/updateUserProfile.ts +++ b/packages/api/src/resolvers/mutations/users/updateUserProfile.ts @@ -14,7 +14,7 @@ export default async function updateUserProfile( log(`mutation updateUserProfile ${normalizedUserId}`, { userId }); if (!(await modules.users.userExists({ userId: normalizedUserId }))) - throw UserNotFoundError({ id: normalizedUserId }); + throw UserNotFoundError({ userId: normalizedUserId }); return modules.users.updateProfile(normalizedUserId, profile); } diff --git a/packages/api/src/roles/index.ts b/packages/api/src/roles/index.ts index 4fca6b6e0d..e127388e62 100644 --- a/packages/api/src/roles/index.ts +++ b/packages/api/src/roles/index.ts @@ -113,6 +113,7 @@ const actions: Record = [ 'heartbeat', 'confirmMediaUpload', 'viewStatistics', + 'removeUser', ].reduce((oldValue, actionValue) => { const newValue = oldValue; newValue[actionValue] = actionValue; diff --git a/packages/api/src/roles/loggedIn.ts b/packages/api/src/roles/loggedIn.ts index abc5a7bb24..55cc2bfbfb 100644 --- a/packages/api/src/roles/loggedIn.ts +++ b/packages/api/src/roles/loggedIn.ts @@ -202,6 +202,7 @@ export const loggedIn = (role: any, actions: Record) => { role.allow(actions.viewUserProductReviews, isMyself); role.allow(actions.viewUserTokens, isMyself); role.allow(actions.updateUser, isMyself); + role.allow(actions.removeUser, isMyself); role.allow(actions.sendEmail, isOwnedEmailAddress); role.allow(actions.viewOrder, isOwnedOrder); role.allow(actions.updateOrder, isOwnedOrder); diff --git a/packages/api/src/schema/mutation.ts b/packages/api/src/schema/mutation.ts index 35febd8c2e..0b1c5cd8b0 100644 --- a/packages/api/src/schema/mutation.ts +++ b/packages/api/src/schema/mutation.ts @@ -304,7 +304,12 @@ export default [ """ Remove any user or logged in user if userId is not provided """ - removeUser(userId: ID): User! + removeUser(userId: ID, removeUserReviews: Boolean): User! + + """ + Remove product reviews of a user + """ + removeUserProductReviews(userId: ID!): Boolean! """ Enroll a new user, setting enroll to true will let the user choose his password (e-mail gets sent) diff --git a/packages/core-enrollments/src/module/configureEnrollmentsModule.ts b/packages/core-enrollments/src/module/configureEnrollmentsModule.ts index 3c5e754dea..ae02512223 100644 --- a/packages/core-enrollments/src/module/configureEnrollmentsModule.ts +++ b/packages/core-enrollments/src/module/configureEnrollmentsModule.ts @@ -99,6 +99,7 @@ export interface EnrollmentMutations { params: { status: EnrollmentStatus; info?: string }, unchainedAPI, ) => Promise; + deleteInactiveUserEnrollments: (userId: string) => Promise; } export type EnrollmentsModule = EnrollmentQueries & @@ -505,7 +506,7 @@ export const configureEnrollmentsModule = async ({ }, }, ); - await emit('ORDER_REMOVE', { enrollmentId }); + await emit('ENROLLMENT_REMOVE', { enrollmentId }); return deletedCount; }, @@ -554,5 +555,12 @@ export const configureEnrollmentsModule = async ({ }, updateStatus, + deleteInactiveUserEnrollments: async (userId: string) => { + const { deletedCount } = await Enrollments.deleteMany({ + userId, + status: { $in: [null, EnrollmentStatus.INITIAL, EnrollmentStatus.TERMINATED] }, + }); + return deletedCount; + }, }; }; diff --git a/packages/core-orders/src/module/configureOrderDeliveriesModule.ts b/packages/core-orders/src/module/configureOrderDeliveriesModule.ts index 7633f83913..c543393f1b 100644 --- a/packages/core-orders/src/module/configureOrderDeliveriesModule.ts +++ b/packages/core-orders/src/module/configureOrderDeliveriesModule.ts @@ -45,6 +45,7 @@ export type OrderDeliveriesModule = { ) => Promise; updateCalculation: (orderDelivery: OrderDelivery, unchainedAPI) => Promise; + deleteOrderDeliveries: (orderId: string) => Promise; }; const ORDER_DELIVERY_EVENTS: string[] = ['ORDER_DELIVER', 'ORDER_UPDATE_DELIVERY']; @@ -270,5 +271,9 @@ export const configureOrderDeliveriesModule = ({ }, ); }, + deleteOrderDeliveries: async (orderId: string) => { + const { deletedCount } = await OrderDeliveries.deleteMany({ orderId }); + return deletedCount; + }, }; }; diff --git a/packages/core-orders/src/module/configureOrderDiscountsModule.ts b/packages/core-orders/src/module/configureOrderDiscountsModule.ts index 76715eed5a..b45d124c37 100644 --- a/packages/core-orders/src/module/configureOrderDiscountsModule.ts +++ b/packages/core-orders/src/module/configureOrderDiscountsModule.ts @@ -40,6 +40,7 @@ export type OrderDiscountsModule = { create: (doc: OrderDiscount) => Promise; update: (orderDiscountId: string, doc: OrderDiscount) => Promise; delete: (orderDiscountId: string, unchainedAPI) => Promise; + deleteOrderDiscounts: (orderId: string) => Promise; }; const ORDER_DISCOUNT_EVENTS: string[] = [ @@ -251,5 +252,9 @@ export const configureOrderDiscountsModule = ({ await emit('ORDER_UPDATE_DISCOUNT', { discount }); return discount; }, + deleteOrderDiscounts: async (orderId: string) => { + const { deletedCount } = await OrderDiscounts.deleteMany({ orderId }); + return deletedCount; + }, }; }; diff --git a/packages/core-orders/src/module/configureOrderPaymentsModule.ts b/packages/core-orders/src/module/configureOrderPaymentsModule.ts index 5e1705f540..f23a02a934 100644 --- a/packages/core-orders/src/module/configureOrderPaymentsModule.ts +++ b/packages/core-orders/src/module/configureOrderPaymentsModule.ts @@ -80,6 +80,7 @@ export type OrderPaymentsModule = { ) => Promise; updateCalculation: (orderPayment: OrderPayment, unchainedAPI) => Promise; + deleteOrderPayments: (orderId: string) => Promise; }; const ORDER_PAYMENT_EVENTS: string[] = ['ORDER_UPDATE_PAYMENT', 'ORDER_SIGN_PAYMENT', 'ORDER_PAY']; @@ -409,5 +410,9 @@ export const configureOrderPaymentsModule = ({ }, ); }, + deleteOrderPayments: async (orderId: string) => { + const { deletedCount } = await OrderPayments.deleteMany({ orderId }); + return deletedCount; + }, }; }; diff --git a/packages/core-orders/src/module/configureOrderPositionsModule.ts b/packages/core-orders/src/module/configureOrderPositionsModule.ts index 8300fdaaa6..d399b3b2cb 100644 --- a/packages/core-orders/src/module/configureOrderPositionsModule.ts +++ b/packages/core-orders/src/module/configureOrderPositionsModule.ts @@ -61,6 +61,7 @@ export type OrderPositionsModule = { params: { order: Order; product: Product }, unchainedAPI, ) => Promise; + deleteOrderPositions: (orderId: string) => Promise; }; const ORDER_POSITION_EVENTS: string[] = [ @@ -349,5 +350,9 @@ export const configureOrderPositionsModule = ({ return upsertedOrderPosition; }, + deleteOrderPositions: async (orderId: string) => { + const { deletedCount } = await OrderPositions.deleteMany({ orderId }); + return deletedCount; + }, }; }; diff --git a/packages/core-orders/src/module/configureOrdersModule-queries.ts b/packages/core-orders/src/module/configureOrdersModule-queries.ts index f3255b825e..bea3d794cc 100644 --- a/packages/core-orders/src/module/configureOrdersModule-queries.ts +++ b/packages/core-orders/src/module/configureOrdersModule-queries.ts @@ -43,7 +43,6 @@ export const configureOrdersModuleQueries = ({ Orders }: { Orders: mongodb.Colle const orderCount = await Orders.countDocuments(buildFindSelector(query)); return orderCount; }, - findOrder: async ( { orderId, diff --git a/packages/core-orders/src/module/configureOrdersModule.ts b/packages/core-orders/src/module/configureOrdersModule.ts index 6f7bfebd68..024a184147 100644 --- a/packages/core-orders/src/module/configureOrdersModule.ts +++ b/packages/core-orders/src/module/configureOrdersModule.ts @@ -25,6 +25,7 @@ export type OrdersModule = OrderQueries & OrderTransformations & OrderProcessing & OrderMutations & { + // Sub entities deliveries: OrderDeliveriesModule; discounts: OrderDiscountsModule; positions: OrderPositionsModule; diff --git a/packages/core-products/src/mock/product.ts b/packages/core-products/src/mock/product.ts index dbe8e816ae..4377ec2801 100644 --- a/packages/core-products/src/mock/product.ts +++ b/packages/core-products/src/mock/product.ts @@ -6,10 +6,8 @@ export default { authorId: 'PKve0k9fLCUzn2EUi', tags: [], created: new Date('2022-10-28T10:41:13.346Z'), - createdBy: 'PKve0k9fLCUzn2EUi', slugs: ['test'], updated: new Date('2022-12-04T13:50:24.245Z'), - updatedBy: 'PKve0k9fLCUzn2EUi', commerce: { pricing: [ { diff --git a/packages/core-products/src/module/configureProductReviewsModule.ts b/packages/core-products/src/module/configureProductReviewsModule.ts index b39e8e91e6..b7154a0f26 100644 --- a/packages/core-products/src/module/configureProductReviewsModule.ts +++ b/packages/core-products/src/module/configureProductReviewsModule.ts @@ -220,7 +220,6 @@ export const configureProductReviewsModule = async ({ return productReview; }, - votes: { userIdsThatVoted, diff --git a/packages/core-quotations/src/module/configureQuotationsModule.ts b/packages/core-quotations/src/module/configureQuotationsModule.ts index 6c3c43a1e0..00a42ed27c 100644 --- a/packages/core-quotations/src/module/configureQuotationsModule.ts +++ b/packages/core-quotations/src/module/configureQuotationsModule.ts @@ -30,6 +30,7 @@ export interface QuotationQueries { ) => Promise>; count: (query: QuotationQuery) => Promise; openQuotationWithProduct: (param: { productId: string }) => Promise; + deleteRequestedUserQuotations: (userId: string) => Promise; } // Transformations @@ -398,7 +399,13 @@ export const configureQuotationsModule = async ({ return quotation; }, - + deleteRequestedUserQuotations: async (userId: string) => { + const { deletedCount } = await Quotations.deleteMany({ + userId, + status: { $in: [QuotationStatus.REQUESTED, null] }, + }); + return deletedCount; + }, updateContext: updateQuotationFields(['context']), updateProposal: updateQuotationFields(['price', 'expires', 'meta']), diff --git a/packages/core-quotations/src/quotations-index.ts b/packages/core-quotations/src/quotations-index.ts index 2b14d5a42f..36da78cfb6 100644 --- a/packages/core-quotations/src/quotations-index.ts +++ b/packages/core-quotations/src/quotations-index.ts @@ -8,3 +8,5 @@ export { QuotationAdapter } from './director/QuotationAdapter.js'; export { QuotationDirector } from './director/QuotationDirector.js'; export { QuotationError } from './director/QuotationError.js'; + +export { configureQuotationsModule } from './module/configureQuotationsModule.js'; diff --git a/packages/core-users/src/module/configureUsersModule.ts b/packages/core-users/src/module/configureUsersModule.ts index 346ae432cd..7564820619 100644 --- a/packages/core-users/src/module/configureUsersModule.ts +++ b/packages/core-users/src/module/configureUsersModule.ts @@ -15,6 +15,8 @@ import { userSettings, UserSettingsOptions } from '../users-settings.js'; import { configureUsersWebAuthnModule, UsersWebAuthnModule } from './configureUsersWebAuthnModule.js'; import * as pbkdf2 from './pbkdf2.js'; import * as sha256 from './sha256.js'; +import crypto from 'crypto'; +import { UnchainedCore } from '@unchainedshop/core'; export type UsersModule = { // Submodules @@ -71,7 +73,7 @@ export type UsersModule = { _id: string, { profile, meta }: { profile?: UserProfile; meta?: any }, ) => Promise; - delete: (userId: string) => Promise; + delete: (params: { userId: string }, context: UnchainedCore) => Promise; updateRoles: (_id: string, roles: Array) => Promise; updateTags: (_id: string, tags: Array) => Promise; updateUser: ( @@ -88,6 +90,9 @@ export type UsersModule = { }, ) => Promise; removePushSubscription: (userId: string, p256dh: string) => Promise; + hashPassword(password: string): Promise<{ + pbkdf2: string; + }>; }; const USER_EVENTS = [ @@ -126,9 +131,8 @@ export const configureUsersModule = async ({ db, options, migrationRepository, -}: ModuleInput) => { +}: ModuleInput): Promise => { userSettings.configureSettings(options || {}, db); - registerEvents(USER_EVENTS); const Users = await UsersCollection(db); @@ -145,7 +149,7 @@ export const configureUsersModule = async ({ return userCount; }, - async findUserById(userId: string): Promise { + findUserById: async (userId: string): Promise => { if (!userId) return null; return Users.findOne(generateDbFilterById(userId), {}); }, @@ -276,9 +280,7 @@ export const configureUsersModule = async ({ }, async userExists({ userId }: { userId: string }): Promise { - const selector = generateDbFilterById(userId); - selector.deleted = null; // skip deleted users when checked for existance! - const userCount = await Users.countDocuments(selector, { limit: 1 }); + const userCount = await Users.countDocuments({ _id: userId, deleted: null }, { limit: 1 }); return userCount === 1; }, @@ -583,38 +585,48 @@ export const configureUsersModule = async ({ return user; }, - delete: async (userId: string): Promise => { - const userFilter = generateDbFilterById(userId); - - const existingUser = await Users.findOne(userFilter, { - projection: { emails: true, username: true }, - }); - if (!existingUser) return null; + delete: async ({ userId }: { userId: string }, context: UnchainedCore): Promise => { + const { services, modules } = context as UnchainedCore; - const uuid = crypto.randomUUID(); - const obfuscatedEmails = existingUser.emails?.flatMap(({ address, verified }) => { - if (!verified) return []; - return [ - { - address: `${address}@${uuid}.unchained.local`, - verified: true, + const user = await Users.findOneAndUpdate( + { _id: userId }, + { + $set: { + username: `deleted-${Date.now()}`, + deleted: new Date(), + emails: [], + roles: [], + profile: null, + lastBillingAddress: null, + services: {}, + pushSubscriptions: [], + avatarId: null, + initialPassword: false, + lastContact: null, + lastLogin: null, }, - ]; - }); + }, + { returnDocument: 'after' }, + ); - const obfuscatedUsername = existingUser.username ? `${existingUser.username}-${uuid}` : null; + if (!user) return null; - Users.updateOne(userFilter, { - $set: { - emails: obfuscatedEmails, - username: obfuscatedUsername, - services: {}, - }, - }); + await modules.bookmarks.deleteByUserId(userId); + await services.orders.deleteUserCarts(userId, context as UnchainedCore); + await modules.quotations.deleteRequestedUserQuotations(userId); + await modules.enrollments.deleteInactiveUserEnrollments(userId); + + const ordersCount = await modules.orders.count({ userId, includeCarts: true }); + const quotationsCount = await modules.quotations.count({ userId }); + const reviewsCount = await modules.products.reviews.count({ authorId: userId }); + const enrollmentsCount = await modules.enrollments.count({ userId }); + const tokens = await modules.warehousing.findTokensForUser(user); + if (!ordersCount && !reviewsCount && !enrollmentsCount && !quotationsCount && !tokens?.length) { + await Users.deleteOne({ _id: userId }); + } - const user = await Users.findOneAndDelete(userFilter); await emit('USER_REMOVE', { - user: removeConfidentialServiceHashes(user), + user, }); return user; }, diff --git a/packages/core-users/tests/mock/user-mock.ts b/packages/core-users/tests/mock/user-mock.ts index 116015668a..dcf1848db3 100644 --- a/packages/core-users/tests/mock/user-mock.ts +++ b/packages/core-users/tests/mock/user-mock.ts @@ -2,7 +2,7 @@ export default { _id: 'PKve0k9fLCUzn2EUi', guest: false, initialPassword: false, - lastBillingAddress: {}, + lastBillingAddress: null, profile: { address: { firstName: null, @@ -69,5 +69,4 @@ export default { countryCode: 'CH', }, updated: new Date('2022-11-30T11:02:19.624Z'), - updatedBy: 'PKve0k9fLCUzn2EUi', }; diff --git a/packages/core/src/core-index.ts b/packages/core/src/core-index.ts index 3c8c888c02..7d8269fefd 100644 --- a/packages/core/src/core-index.ts +++ b/packages/core/src/core-index.ts @@ -185,6 +185,7 @@ export const initCore = async ({ }); const users = await configureUsersModule({ db, + options: options.users, migrationRepository, }); const warehousing = await configureWarehousingModule({ diff --git a/packages/core/src/services/deleteUserCartsService.ts b/packages/core/src/services/deleteUserCartsService.ts new file mode 100644 index 0000000000..e94711102a --- /dev/null +++ b/packages/core/src/services/deleteUserCartsService.ts @@ -0,0 +1,22 @@ +import { UnchainedCore } from '../core-index.js'; + +export const deleteUserCartsService = async ( + userId: string, + unchainedAPI: UnchainedCore & { countryContext?: string }, +) => { + try { + const carts = await unchainedAPI.modules.orders.findOrders({ userId, status: null }); + + for (const userCart of carts) { + await unchainedAPI.modules.orders.positions.deleteOrderPositions(userCart?._id); + await unchainedAPI.modules.orders.payments.deleteOrderPayments(userCart?._id); + await unchainedAPI.modules.orders.deliveries.deleteOrderDeliveries(userCart?._id); + await unchainedAPI.modules.orders.discounts.deleteOrderDiscounts(userCart?._id); + await unchainedAPI.modules.orders.delete(userCart?._id); + } + return true; + } catch (e) { + console.error(e); + return false; + } +}; diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 657e1fa1c9..181ea5e0a3 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -12,6 +12,7 @@ import { nextUserCartService } from './nextUserCartService.js'; import { removeProductService } from './removeProductService.js'; import { initCartProvidersService } from './initCartProviders.js'; import { updateCalculationService } from './updateCalculationService.js'; +import { deleteUserCartsService } from './deleteUserCartsService.js'; const services = { bookmarks: { @@ -30,6 +31,7 @@ const services = { nextUserCart: nextUserCartService, initCartProviders: initCartProvidersService, updateCalculation: updateCalculationService, + deleteUserCarts: deleteUserCartsService, }, products: { removeProduct: removeProductService,