mirror of
https://github.com/wassname/talk.git
synced 2026-08-02 13:10:23 +08:00
[CORL-523] Document GraphQL mutations for managing accounts (#2544)
* Create preliminary endpoint for updating user names via API CORL-523 * Create preliminary endpoint for updating user emails via API CORL-523 * Create preliminary endpoint for deleting users via the API CORL-523 * Hook up the delete, update email, and update user name endpoints CORL-523 * Update readme to include preliminary GDPR endpoint instructions CORL-523 * Uncomment limiters on updateEmail endpoint My mistake leaving these commented while testing, should not have been committed. CORL-523 * Run DocToc on the README to generate table of contents CORL-523 * feat: enhanced documentation on account management edges * fix: prevent double delete of user account * fix: swapped order of params * Update README.md * Update README.md * fix: remove unknown field title * fix: remove unknown field title on story
This commit is contained in:
@@ -1,13 +1,9 @@
|
||||
import { DateTime } from "luxon";
|
||||
import { Collection, Db } from "mongodb";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { CommentAction } from "coral-server/models/action/comment";
|
||||
import { createCollection } from "coral-server/models/helpers";
|
||||
import { Story } from "coral-server/models/story";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { retrieveUserScheduledForDeletion } from "coral-server/models/user";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { deleteUser } from "coral-server/services/users/delete";
|
||||
|
||||
import {
|
||||
ScheduledJob,
|
||||
@@ -15,17 +11,6 @@ import {
|
||||
ScheduledJobGroup,
|
||||
} from "./scheduled";
|
||||
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
// TODO: extract this out to a separate file so it can be re-used elsewhere
|
||||
const collections = {
|
||||
users: createCollection<User>("users"),
|
||||
comments: createCollection<Comment>("comments"),
|
||||
stories: createCollection<Story>("stories"),
|
||||
tenants: createCollection<Tenant>("tenants"),
|
||||
commentActions: createCollection<CommentAction>("commentActions"),
|
||||
};
|
||||
|
||||
interface Options {
|
||||
mongo: Db;
|
||||
mailerQueue: MailerQueue;
|
||||
@@ -58,25 +43,13 @@ const deleteScheduledAccounts: ScheduledJobCommand<Options> = async ({
|
||||
|
||||
while (true) {
|
||||
const now = new Date();
|
||||
const rescheduledDeletionDate = DateTime.fromJSDate(now)
|
||||
.plus({ hours: 1 })
|
||||
.toJSDate();
|
||||
|
||||
const { value: user } = await collections.users(mongo).findOneAndUpdate(
|
||||
const user = await retrieveUserScheduledForDeletion(
|
||||
mongo,
|
||||
tenant.id,
|
||||
{
|
||||
tenantID: tenant.id,
|
||||
scheduledDeletionDate: { $lte: now },
|
||||
hours: 1,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
scheduledDeletionDate: rescheduledDeletionDate,
|
||||
},
|
||||
},
|
||||
{
|
||||
// We want to get back the user with
|
||||
// modified scheduledDeletionDate
|
||||
returnOriginal: false,
|
||||
}
|
||||
now
|
||||
);
|
||||
if (!user) {
|
||||
log.debug("no more users were scheduled for deletion");
|
||||
@@ -85,177 +58,26 @@ const deleteScheduledAccounts: ScheduledJobCommand<Options> = async ({
|
||||
|
||||
log.info({ userID: user.id }, "deleting user");
|
||||
|
||||
await deleteUser(mongo, mailerQueue, user.id, user.tenantID, now);
|
||||
await deleteUser(mongo, user.id, tenant.id, now);
|
||||
|
||||
// If the user has an email, then send them a confirmation that their account
|
||||
// was deleted.
|
||||
if (user.email) {
|
||||
await mailerQueue.add({
|
||||
tenantID: tenant.id,
|
||||
message: {
|
||||
to: user.email,
|
||||
},
|
||||
template: {
|
||||
name: "account-notification/delete-request-completed",
|
||||
context: {
|
||||
organizationContactEmail: tenant.organization.contactEmail,
|
||||
organizationName: tenant.organization.name,
|
||||
organizationURL: tenant.organization.url,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function executeBulkOperations<T>(
|
||||
collection: Collection<T>,
|
||||
operations: any[]
|
||||
) {
|
||||
// TODO: (wyattjoh) fix types here to support actual types when upstream changes applied
|
||||
const bulk: any = collection.initializeUnorderedBulkOp();
|
||||
|
||||
for (const operation of operations) {
|
||||
bulk.raw(operation);
|
||||
}
|
||||
|
||||
await bulk.execute();
|
||||
}
|
||||
|
||||
interface Batch {
|
||||
comments: any[];
|
||||
stories: any[];
|
||||
}
|
||||
|
||||
async function deleteUserActionCounts(
|
||||
mongo: Db,
|
||||
userID: string,
|
||||
tenantID: string
|
||||
) {
|
||||
const batch: Batch = {
|
||||
comments: [],
|
||||
stories: [],
|
||||
};
|
||||
|
||||
async function processBatch() {
|
||||
await executeBulkOperations<Comment>(
|
||||
collections.comments(mongo),
|
||||
batch.comments
|
||||
);
|
||||
batch.comments = [];
|
||||
|
||||
await executeBulkOperations<Story>(
|
||||
collections.stories(mongo),
|
||||
batch.stories
|
||||
);
|
||||
batch.stories = [];
|
||||
}
|
||||
|
||||
const cursor = collections
|
||||
.commentActions(mongo)
|
||||
.find({ tenantID, userID, actionType: "REACTION" });
|
||||
while (await cursor.hasNext()) {
|
||||
const action = await cursor.next();
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.comments.push({
|
||||
updateOne: {
|
||||
filter: { tenantID, id: action.commentID },
|
||||
update: {
|
||||
$inc: {
|
||||
"revisions.$[revisions].actionCounts.REACTION": -1,
|
||||
"actionCounts.REACTION": -1,
|
||||
},
|
||||
},
|
||||
arrayFilters: [{ "revisions.id": action.commentRevisionID }],
|
||||
},
|
||||
});
|
||||
|
||||
batch.stories.push({
|
||||
updateOne: {
|
||||
filter: { tenantID, id: action.storyID },
|
||||
update: {
|
||||
$inc: {
|
||||
"commentCounts.action.REACTION": -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
batch.comments.length >= BATCH_SIZE ||
|
||||
batch.stories.length >= BATCH_SIZE
|
||||
) {
|
||||
await processBatch();
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.comments.length > 0 || batch.stories.length > 0) {
|
||||
await processBatch();
|
||||
}
|
||||
|
||||
await collections.commentActions(mongo).deleteMany({
|
||||
tenantID,
|
||||
userID,
|
||||
actionType: "REACTION",
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteUserComments(
|
||||
mongo: Db,
|
||||
authorID: string,
|
||||
tenantID: string
|
||||
) {
|
||||
await collections.comments(mongo).updateMany(
|
||||
{ tenantID, authorID },
|
||||
{
|
||||
$set: {
|
||||
authorID: null,
|
||||
revisions: [],
|
||||
tags: [],
|
||||
deleted: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteUser(
|
||||
mongo: Db,
|
||||
mailer: MailerQueue,
|
||||
userID: string,
|
||||
tenantID: string,
|
||||
now: Date
|
||||
) {
|
||||
const user = await collections.users(mongo).findOne({ id: userID, tenantID });
|
||||
if (!user) {
|
||||
throw new Error("could not find user by ID");
|
||||
}
|
||||
|
||||
const tenant = await collections.tenants(mongo).findOne({ id: tenantID });
|
||||
if (!tenant) {
|
||||
throw new Error("could not find tenant by ID");
|
||||
}
|
||||
|
||||
// Delete the user's action counts.
|
||||
await deleteUserActionCounts(mongo, userID, tenantID);
|
||||
|
||||
// Delete the user's comments.
|
||||
await deleteUserComments(mongo, userID, tenantID);
|
||||
|
||||
// Mark the user as deleted.
|
||||
await collections.users(mongo).updateOne(
|
||||
{ tenantID, id: userID },
|
||||
{
|
||||
$set: {
|
||||
profiles: [],
|
||||
deletedAt: now,
|
||||
},
|
||||
$unset: {
|
||||
email: "",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// If the user has an email, then send them a confirmation that their account
|
||||
// was deleted.
|
||||
if (user.email) {
|
||||
await mailer.add({
|
||||
tenantID,
|
||||
message: {
|
||||
to: user.email,
|
||||
},
|
||||
template: {
|
||||
name: "account-notification/delete-request-completed",
|
||||
context: {
|
||||
organizationContactEmail: tenant.organization.contactEmail,
|
||||
organizationName: tenant.organization.name,
|
||||
organizationURL: tenant.organization.url,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,14 @@ import {
|
||||
updateUsernameByID,
|
||||
} from "coral-server/services/users";
|
||||
import { invite } from "coral-server/services/users/auth/invite";
|
||||
import { deleteUser } from "coral-server/services/users/delete";
|
||||
|
||||
import {
|
||||
GQLBanUserInput,
|
||||
GQLCancelAccountDeletionInput,
|
||||
GQLCreateTokenInput,
|
||||
GQLDeactivateTokenInput,
|
||||
GQLDeleteUserAccountInput,
|
||||
GQLIgnoreUserInput,
|
||||
GQLInviteUsersInput,
|
||||
GQLRemoveUserBanInput,
|
||||
@@ -135,6 +137,15 @@ export const Users = (ctx: TenantContext) => ({
|
||||
),
|
||||
{ "input.password": [ERROR_CODES.PASSWORD_INCORRECT] }
|
||||
),
|
||||
deleteAccount: async (
|
||||
input: GQLDeleteUserAccountInput
|
||||
): Promise<Readonly<User> | null> => {
|
||||
if (input.userID === ctx.user!.id) {
|
||||
throw new Error("cannot delete self immediately");
|
||||
}
|
||||
|
||||
return deleteUser(ctx.mongo, input.userID, ctx.tenant.id, ctx.now);
|
||||
},
|
||||
cancelAccountDeletion: async (
|
||||
input: GQLCancelAccountDeletionInput
|
||||
): Promise<Readonly<User> | null> =>
|
||||
|
||||
@@ -209,4 +209,8 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
user: await ctx.mutators.Users.cancelAccountDeletion(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
deleteUserAccount: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.deleteAccount(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -4363,6 +4363,34 @@ type RequestAccountDeletionPayload {
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
##################
|
||||
# deleteUserAccount
|
||||
##################
|
||||
|
||||
input DeleteUserAccountInput {
|
||||
"""
|
||||
userID is the ID of the User being deleted.
|
||||
"""
|
||||
userID: ID!
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
type DeleteUserAccountPayload {
|
||||
"""
|
||||
user is the User that was deleted.
|
||||
"""
|
||||
user: User
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
##################
|
||||
# cancelAccountDeletion
|
||||
##################
|
||||
@@ -5052,13 +5080,20 @@ type Mutation {
|
||||
input: RequestAccountDeletionInput!
|
||||
): RequestAccountDeletionPayload! @auth
|
||||
|
||||
"""
|
||||
deleteUserAccount will delete the target user now.
|
||||
"""
|
||||
deleteUserAccount(input: DeleteUserAccountInput!): DeleteUserAccountPayload!
|
||||
@auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
cancelAccountDeletion allows the current logged in User to cancel the
|
||||
request to delete their account
|
||||
"""
|
||||
cancelAccountDeletion(
|
||||
input: CancelAccountDeletionInput!
|
||||
): CancelAccountDeletionPayload! @auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
): CancelAccountDeletionPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
|
||||
"""
|
||||
createToken allows an administrator to create a Token based on the current
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { DateTime, DurationObject } from "luxon";
|
||||
import { Db, MongoError } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
@@ -2136,3 +2137,41 @@ export async function pullUserNotificationDigests(
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieveUserScheduledForDeletion will return a user that is scheduled for
|
||||
* deletion as well as create a reschedule date in the future.
|
||||
*
|
||||
* @param mongo the database to pull scheduled users to delete from
|
||||
* @param tenantID the tenant ID to pull users that have been scheduled for
|
||||
* deletion on
|
||||
* @param now the current time
|
||||
*/
|
||||
export async function retrieveUserScheduledForDeletion(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
rescheduledDuration: DurationObject,
|
||||
now: Date
|
||||
) {
|
||||
const rescheduledDeletionDate = DateTime.fromJSDate(now)
|
||||
.plus(rescheduledDuration)
|
||||
.toJSDate();
|
||||
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
scheduledDeletionDate: { $lte: now },
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
scheduledDeletionDate: rescheduledDeletionDate,
|
||||
},
|
||||
},
|
||||
{
|
||||
// We want to get back the user with
|
||||
// modified scheduledDeletionDate
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Collection, Db } from "mongodb";
|
||||
|
||||
import { CommentAction } from "coral-server/models/action/comment";
|
||||
import { createCollection } from "coral-server/models/helpers";
|
||||
import { Story } from "coral-server/models/story";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
// TODO: extract this out to a separate file so it can be re-used elsewhere
|
||||
const collections = {
|
||||
users: createCollection<User>("users"),
|
||||
comments: createCollection<Comment>("comments"),
|
||||
stories: createCollection<Story>("stories"),
|
||||
tenants: createCollection<Tenant>("tenants"),
|
||||
commentActions: createCollection<CommentAction>("commentActions"),
|
||||
};
|
||||
|
||||
async function executeBulkOperations<T>(
|
||||
collection: Collection<T>,
|
||||
operations: any[]
|
||||
) {
|
||||
// TODO: (wyattjoh) fix types here to support actual types when upstream changes applied
|
||||
const bulk: any = collection.initializeUnorderedBulkOp();
|
||||
|
||||
for (const operation of operations) {
|
||||
bulk.raw(operation);
|
||||
}
|
||||
|
||||
await bulk.execute();
|
||||
}
|
||||
|
||||
interface Batch {
|
||||
comments: any[];
|
||||
stories: any[];
|
||||
}
|
||||
|
||||
async function deleteUserActionCounts(
|
||||
mongo: Db,
|
||||
userID: string,
|
||||
tenantID: string
|
||||
) {
|
||||
const batch: Batch = {
|
||||
comments: [],
|
||||
stories: [],
|
||||
};
|
||||
|
||||
async function processBatch() {
|
||||
await executeBulkOperations<Comment>(
|
||||
collections.comments(mongo),
|
||||
batch.comments
|
||||
);
|
||||
batch.comments = [];
|
||||
|
||||
await executeBulkOperations<Story>(
|
||||
collections.stories(mongo),
|
||||
batch.stories
|
||||
);
|
||||
batch.stories = [];
|
||||
}
|
||||
|
||||
const cursor = collections
|
||||
.commentActions(mongo)
|
||||
.find({ tenantID, userID, actionType: "REACTION" });
|
||||
while (await cursor.hasNext()) {
|
||||
const action = await cursor.next();
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.comments.push({
|
||||
updateOne: {
|
||||
filter: { tenantID, id: action.commentID },
|
||||
update: {
|
||||
$inc: {
|
||||
"revisions.$[revisions].actionCounts.REACTION": -1,
|
||||
"actionCounts.REACTION": -1,
|
||||
},
|
||||
},
|
||||
arrayFilters: [{ "revisions.id": action.commentRevisionID }],
|
||||
},
|
||||
});
|
||||
|
||||
batch.stories.push({
|
||||
updateOne: {
|
||||
filter: { tenantID, id: action.storyID },
|
||||
update: {
|
||||
$inc: {
|
||||
"commentCounts.action.REACTION": -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
batch.comments.length >= BATCH_SIZE ||
|
||||
batch.stories.length >= BATCH_SIZE
|
||||
) {
|
||||
await processBatch();
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.comments.length > 0 || batch.stories.length > 0) {
|
||||
await processBatch();
|
||||
}
|
||||
|
||||
await collections.commentActions(mongo).deleteMany({
|
||||
tenantID,
|
||||
userID,
|
||||
actionType: "REACTION",
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteUserComments(
|
||||
mongo: Db,
|
||||
authorID: string,
|
||||
tenantID: string
|
||||
) {
|
||||
await collections.comments(mongo).updateMany(
|
||||
{ tenantID, authorID },
|
||||
{
|
||||
$set: {
|
||||
authorID: null,
|
||||
revisions: [],
|
||||
tags: [],
|
||||
deleted: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteUser(
|
||||
mongo: Db,
|
||||
userID: string,
|
||||
tenantID: string,
|
||||
now: Date
|
||||
) {
|
||||
const user = await collections.users(mongo).findOne({ id: userID, tenantID });
|
||||
if (!user) {
|
||||
throw new Error("could not find user by ID");
|
||||
}
|
||||
|
||||
// Check to see if the user was already deleted.
|
||||
if (user.deletedAt) {
|
||||
throw new Error("user was already deleted");
|
||||
}
|
||||
|
||||
const tenant = await collections.tenants(mongo).findOne({ id: tenantID });
|
||||
if (!tenant) {
|
||||
throw new Error("could not find tenant by ID");
|
||||
}
|
||||
|
||||
// Delete the user's action counts.
|
||||
await deleteUserActionCounts(mongo, userID, tenantID);
|
||||
|
||||
// Delete the user's comments.
|
||||
await deleteUserComments(mongo, userID, tenantID);
|
||||
|
||||
// Mark the user as deleted.
|
||||
const result = await collections.users(mongo).findOneAndUpdate(
|
||||
{ tenantID, id: userID },
|
||||
{
|
||||
$set: {
|
||||
profiles: [],
|
||||
deletedAt: now,
|
||||
},
|
||||
$unset: {
|
||||
email: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
Reference in New Issue
Block a user