Move user deletion to worker (#15586)

* WIP(delete): remove business logic from controller

* fix(deletion): handle group leave logic on app server still

* fix(lint): unused import

* fix(import): bracket syntax

* fix(test): adapt test for worker flow

* fix(deletion): update delete/feedback form copy

* fix(text): don't break to new paragraph about Gems

* fix(deletion): remove orphaned chat messages

* Revert "fix(deletion): handle group leave logic on app server still"

This reverts commit 9db541f4c3.

* fix(tests): remove tests
These can potentially be tested in the worker's suite? They target functionality that the group leave route handles within the deletion flow

* fix(lint): no-undef

* refactor redis setup into own file and use ioredis

* use bullmq directly to schedule jobs

* add space

* add key prefix

* add semicolon

* fix(jobs): update redis package

---------

Co-authored-by: Phillip Thelen <phillip@habitica.com>
This commit is contained in:
Kalista Payne
2026-05-26 17:43:49 -05:00
committed by GitHub
parent b57fb94579
commit fb2eaa3950
17 changed files with 399 additions and 188 deletions
+10 -19
View File
@@ -9,10 +9,6 @@ import {
BadRequest,
NotAuthorized,
} from '../../libs/errors';
import {
basicFields as basicGroupFields,
model as Group,
} from '../../models/group';
import * as Tasks from '../../models/task';
import * as passwordUtils from '../../libs/password';
import {
@@ -22,6 +18,7 @@ import {
getUserInfo,
sendTxn,
} from '../../libs/email';
import worker from '../../libs/worker';
import * as inboxLib from '../../libs/inbox';
import * as userLib from '../../libs/user';
import { model as UserHistory } from '../../models/userHistory';
@@ -297,21 +294,6 @@ api.deleteUser = {
throw new NotAuthorized(res.t('cannotDeleteActiveAccount'));
}
const types = ['party', 'guilds'];
const groupFields = basicGroupFields.concat(' leader memberCount purchased');
const groupsUserIsMemberOf = await Group.getGroups({ user, types, groupFields });
const groupLeavePromises = groupsUserIsMemberOf.map(group => group.leave(user, 'remove-all'));
await Promise.all(groupLeavePromises);
await Tasks.Task.deleteMany({
userId: user._id,
}).exec();
await user.deleteOne();
if (feedback) {
sendTxn({ email: TECH_ASSISTANCE_EMAIL }, 'admin-feedback', [
{ name: 'PROFILE_NAME', content: user.profile.name },
@@ -323,6 +305,15 @@ api.deleteUser = {
]);
}
worker.sendJob('deleteUser', {
identifier: user._id,
data: {
userId: user._id,
deleteAccount: true,
deleteAmplitude: true,
},
});
res.respond(200, {});
},
};
+3 -2
View File
@@ -1,4 +1,4 @@
import { sendJob } from '../../libs/worker';
import worker from '../../libs/worker';
import { authWithHeaders } from '../../middlewares/auth';
import { ensurePermission } from '../../middlewares/ensureAccessRight';
import { TransactionModel as Transaction } from '../../models/transaction';
@@ -48,7 +48,8 @@ api.deleteMember = {
req.checkQuery('deleteAmplitude').optional().isIn(['true', 'false']);
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
sendJob('delete-user', {
await worker.sendJob('deleteUser', {
identifier: req.params.memberId,
data: {
userId: req.params.memberId,
deleteAccount: req.query.deleteAccount === 'true',
+4 -1
View File
@@ -24,7 +24,10 @@ api.getReady = {
middlewares: [disableCache],
async handler (req, res) {
// This allows kubernetes to determine if the server is ready to receive traffic
if (!SERVER_STATUS.MONGODB || !SERVER_STATUS.REDIS || !SERVER_STATUS.EXPRESS) {
if (!SERVER_STATUS.MONGODB
|| !SERVER_STATUS.RATE_LIMITER
|| !SERVER_STATUS.WORKER
|| !SERVER_STATUS.EXPRESS) {
res.respond(503, {
status: 'not ready',
});
+3 -2
View File
@@ -2,7 +2,7 @@ import nconf from 'nconf';
import { TAVERN_ID } from '../models/group'; // eslint-disable-line import/no-cycle
import { encrypt } from './encryption';
import common from '../../common';
import { sendJob } from './worker';
import worker from './worker';
const IS_PROD = nconf.get('IS_PROD');
const BASE_URL = nconf.get('BASE_URL');
@@ -148,7 +148,8 @@ export async function sendTxn (mailingInfoArray, emailType, variables, personalV
}
if (IS_PROD && mailingInfoArray.length > 0) {
return sendJob('email', {
return worker.sendJob('email', {
identifier: emailType,
data: {
emailType,
to: mailingInfoArray,
+22
View File
@@ -0,0 +1,22 @@
import IORedis from 'ioredis';
export default function setupRedis (connectionOptions, config) {
const redisConfig = { ...config };
if (connectionOptions.username) {
redisConfig.username = connectionOptions.username;
}
if (connectionOptions.password) {
redisConfig.password = connectionOptions.password;
}
if (connectionOptions.db) {
redisConfig.db = connectionOptions.db;
}
let connection;
const redisUrl = connectionOptions.url;
if (redisUrl) {
connection = new IORedis(redisUrl, redisConfig);
} else {
connection = new IORedis(connectionOptions.port, connectionOptions.host, redisConfig);
}
return connection;
}
+2 -1
View File
@@ -1,6 +1,7 @@
const SERVER_STATUS = {
MONGODB: false,
REDIS: false,
RATE_LIMITER: false,
WORKER: false,
EXPRESS: false,
};
+43 -27
View File
@@ -1,33 +1,49 @@
import got from 'got';
import nconf from 'nconf';
import logger from './logger';
import { Queue } from 'bullmq';
import setupRedis from './redis';
import SERVER_STATUS from './serverStatus';
const EMAIL_SERVER = {
url: nconf.get('EMAIL_SERVER_URL'),
auth: {
user: nconf.get('EMAIL_SERVER_AUTH_USER'),
password: nconf.get('EMAIL_SERVER_AUTH_PASSWORD'),
},
};
let redisClient;
const queues = {};
export function sendJob (type, config) {
const { data, options } = config;
const usedOptions = {
backoff: { delay: 10 * 60 * 1000, type: 'exponential' },
...options,
if (nconf.get('WORKER_REDIS_URL')) {
redisClient = setupRedis({
url: nconf.get('WORKER_REDIS_URL'),
username: nconf.get('WORKER_REDIS_USERNAME'),
password: nconf.get('WORKER_REDIS_PASSWORD'),
});
redisClient.on('ready', () => {
SERVER_STATUS.WORKER = true;
});
redisClient.on('reconnecting', () => {
SERVER_STATUS.WORKER = false;
});
const queueConfig = {
connection: redisClient,
};
if (nconf.get('WORKER_REDIS_KEY_PREFIX')) {
queueConfig.prefix = nconf.get('WORKER_REDIS_KEY_PREFIX');
}
return got.post(`${EMAIL_SERVER.url}/job`, {
retry: 5, // retry the http request to the email server 5 times
timeout: 60000, // wait up to 60s before timing out
username: EMAIL_SERVER.auth.user,
password: EMAIL_SERVER.auth.password,
json: {
type,
data,
options: usedOptions,
},
}).json().catch(err => logger.error(err, {
extraMessage: 'Error while sending an email.',
}));
queues.email = new Queue('emails', queueConfig);
queues.deleteUser = new Queue('DeleteUsers', queueConfig);
} else {
SERVER_STATUS.WORKER = true;
}
function sendJob (type, config) {
if (!queues[type]) {
return Promise.reject(new Error(`Queue ${type} does not exist`));
}
const { identifier, data } = config;
return queues[type].add(identifier, data);
}
export function getRedisClient () {
return redisClient;
}
export default { sendJob };
+1 -1
View File
@@ -1,5 +1,5 @@
import nconf from 'nconf';
import redis from 'redis';
import redis from 'ioredis';
import {
RateLimiterRedis,
RateLimiterMemory,
+1
View File
@@ -1457,6 +1457,7 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all', keepC
if (members.length === 0) {
promises.push(group.deleteOne());
promises.push(Chat.deleteMany({ groupId: group._id }));
return Promise.all(promises);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import nconf from 'nconf';
import express from 'express';
import http from 'http';
import mongoose from 'mongoose';
import redis from 'redis';
import redis from 'ioredis';
import logger from './libs/logger';
// Setup translations