[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:
Nick Funk
2019-09-10 20:16:40 +07:00
committed by Vinh
parent e86e9d2c68
commit f9e1bf144e
7 changed files with 530 additions and 211 deletions
+28 -206
View File
@@ -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
+39
View File
@@ -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;
}
+180
View File
@@ -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;
}