diff --git a/packages/backend/src/controllers/api/v1/admin/users/delete-user.js b/packages/backend/src/controllers/api/v1/admin/users/delete-user.js new file mode 100644 index 00000000..79fd4308 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/delete-user.js @@ -0,0 +1,10 @@ +import User from '../../../../../models/user.js'; + +export default async (request, response) => { + const id = request.params.userId; + + const user = await User.query().findById(id).throwIfNotFound(); + await user.softRemove(); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/users/delete-user.test.js b/packages/backend/src/controllers/api/v1/admin/users/delete-user.test.js new file mode 100644 index 00000000..a6dbfd93 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/delete-user.test.js @@ -0,0 +1,50 @@ +import { vi, describe, it, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../../test/factories/user'; +import { createRole } from '../../../../../../test/factories/role'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('DELETE /api/v1/admin/users/:userId', () => { + let currentUser, currentUserRole, anotherUser, token; + + beforeEach(async () => { + currentUserRole = await createRole({ key: 'admin' }); + currentUser = await createUser({ roleId: currentUserRole.id }); + + anotherUser = await createUser(); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should soft delete user and respond with no content', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + await request(app) + .delete(`/api/v1/admin/users/${anotherUser.id}`) + .set('Authorization', token) + .expect(204); + }); + + it('should return not found response for not existing user UUID', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + const notExistingUserUUID = Crypto.randomUUID(); + + await request(app) + .delete(`/api/v1/admin/users/${notExistingUserUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + await request(app) + .delete('/api/v1/admin/users/invalidUserUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js index 54e79228..88d61302 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -1,5 +1,5 @@ import bcrypt from 'bcrypt'; -import { DateTime } from 'luxon'; +import { DateTime, Duration } from 'luxon'; import crypto from 'node:crypto'; import appConfig from '../config/app.js'; @@ -20,6 +20,7 @@ import Step from './step.js'; import Subscription from './subscription.ee.js'; import UsageData from './usage-data.ee.js'; import Billing from '../helpers/billing/index.ee.js'; +import deleteUserQueue from '../queues/delete-user.ee.js'; class User extends Base { static tableName = 'users'; @@ -239,6 +240,19 @@ class User extends Base { }); } + async softRemove() { + await this.$query().delete(); + + const jobName = `Delete user - ${this.id}`; + const jobPayload = { id: this.id }; + const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis(); + const jobOptions = { + delay: millisecondsFor30Days, + }; + + await deleteUserQueue.add(jobName, jobPayload, jobOptions); + } + isResetPasswordTokenValid() { if (!this.resetPasswordTokenSentAt) { return false; diff --git a/packages/backend/src/routes/api/v1/admin/users.ee.js b/packages/backend/src/routes/api/v1/admin/users.ee.js index 9752c6a0..3ebb3625 100644 --- a/packages/backend/src/routes/api/v1/admin/users.ee.js +++ b/packages/backend/src/routes/api/v1/admin/users.ee.js @@ -4,6 +4,7 @@ import { authenticateUser } from '../../../../helpers/authentication.js'; import { authorizeAdmin } from '../../../../helpers/authorization.js'; import getUsersAction from '../../../../controllers/api/v1/admin/users/get-users.ee.js'; import getUserAction from '../../../../controllers/api/v1/admin/users/get-user.ee.js'; +import deleteUserAction from '../../../../controllers/api/v1/admin/users/delete-user.js'; const router = Router(); @@ -16,4 +17,11 @@ router.get( asyncHandler(getUserAction) ); +router.delete( + '/:userId', + authenticateUser, + authorizeAdmin, + asyncHandler(deleteUserAction) +); + export default router;