mirror of
https://github.com/wassname/talk.git
synced 2026-09-09 11:38:08 +08:00
[CORL-155] User Suspending and Banning (#2247)
* feat: suspending, banning, now propogation * feat: adapting to `now` * feat: support auth for suspension/banned * feat: added trace-id to requests * feat: new mutation api with hooks support * feat: added user status filtering, current field * feat: Implement filter by status, adapt to new USER_STATUS type, add lookup helper <3 * fix: typo * fix: tests * chore: rename banned status to ban status * test: feature test + lots of test helper improvements e.g. types * fix: add translation to ban user modal * fix: translation * fix: test
This commit is contained in:
@@ -186,7 +186,8 @@ export function filterDuplicateActions<T extends {}>(actions: T[]): T[] {
|
||||
export async function createAction(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
input: CreateActionInput
|
||||
input: CreateActionInput,
|
||||
now = new Date()
|
||||
): Promise<CreateActionResultObject> {
|
||||
const { metadata, additionalDetails, ...filter } = input;
|
||||
|
||||
@@ -198,7 +199,7 @@ export async function createAction(
|
||||
const defaults: Sub<CommentAction, CreateActionInput> = {
|
||||
id,
|
||||
tenantID,
|
||||
createdAt: new Date(),
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Merge the defaults with the input.
|
||||
@@ -244,10 +245,13 @@ export async function createAction(
|
||||
export async function createActions(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
inputs: CreateActionInput[]
|
||||
inputs: CreateActionInput[],
|
||||
now = new Date()
|
||||
): Promise<CreateActionResultObject[]> {
|
||||
// TODO: (wyattjoh) replace with a batch write.
|
||||
return Promise.all(inputs.map(input => createAction(mongo, tenantID, input)));
|
||||
return Promise.all(
|
||||
inputs.map(input => createAction(mongo, tenantID, input, now))
|
||||
);
|
||||
}
|
||||
|
||||
export async function retrieveUserAction(
|
||||
|
||||
@@ -221,10 +221,9 @@ export type CreateCommentInput = Omit<
|
||||
export async function createComment(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
input: CreateCommentInput
|
||||
input: CreateCommentInput,
|
||||
now = new Date()
|
||||
) {
|
||||
const createdAt = new Date();
|
||||
|
||||
// Pull out some useful properties from the input.
|
||||
const { body, actionCounts = {}, ...rest } = input;
|
||||
|
||||
@@ -233,7 +232,7 @@ export async function createComment(
|
||||
id: uuid.v4(),
|
||||
body,
|
||||
actionCounts,
|
||||
createdAt,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// default are the properties set by the application when a new comment is
|
||||
@@ -244,7 +243,7 @@ export async function createComment(
|
||||
replyIDs: [],
|
||||
replyCount: 0,
|
||||
revisions: [revision],
|
||||
createdAt,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Merge the defaults and the input together.
|
||||
@@ -360,10 +359,9 @@ export interface EditComment {
|
||||
export async function editComment(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
input: EditCommentInput
|
||||
input: EditCommentInput,
|
||||
now = new Date()
|
||||
): Promise<EditComment> {
|
||||
const createdAt = new Date();
|
||||
|
||||
const {
|
||||
id,
|
||||
body,
|
||||
@@ -379,7 +377,7 @@ export async function editComment(
|
||||
id: uuid.v4(),
|
||||
body,
|
||||
actionCounts,
|
||||
createdAt,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
const update: Record<string, any> = {
|
||||
|
||||
@@ -81,6 +81,7 @@ export type Settings = GlobalModerationSettings &
|
||||
| "editCommentWindowLength"
|
||||
| "customCSSURL"
|
||||
| "communityGuidelines"
|
||||
| "createdAt"
|
||||
> & {
|
||||
/**
|
||||
* auth is the set of configured authentication integrations.
|
||||
|
||||
@@ -62,9 +62,9 @@ function collection<T = Story>(mongo: Db) {
|
||||
export async function recalculateSharedModerationQueueQueueCounts(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
tenantID: string
|
||||
tenantID: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const now = new Date();
|
||||
const key = commentCountsModerationQueueQueuesKey(tenantID);
|
||||
const freshKey = freshenKey(key);
|
||||
|
||||
@@ -136,9 +136,9 @@ export async function recalculateSharedModerationQueueQueueCounts(
|
||||
export async function recalculateSharedModerationQueueTotalCounts(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
tenantID: string
|
||||
tenantID: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const now = new Date();
|
||||
const key = commentCountsModerationQueueTotalKey(tenantID);
|
||||
const freshKey = freshenKey(key);
|
||||
|
||||
@@ -191,9 +191,9 @@ export async function recalculateSharedModerationQueueTotalCounts(
|
||||
export async function recalculateSharedStatusCommentCounts(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
tenantID: string
|
||||
tenantID: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const now = new Date();
|
||||
const key = commentCountsStatusKey(tenantID);
|
||||
const freshKey = freshenKey(key);
|
||||
|
||||
@@ -265,9 +265,9 @@ export async function recalculateSharedStatusCommentCounts(
|
||||
export async function recalculateSharedActionCommentCounts(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
tenantID: string
|
||||
tenantID: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const now = new Date();
|
||||
const key = commentCountsActionKey(tenantID);
|
||||
const freshKey = freshenKey(key);
|
||||
|
||||
@@ -339,13 +339,14 @@ export async function recalculateSharedActionCommentCounts(
|
||||
export async function recalculateSharedCommentCounts(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
tenantID: string
|
||||
tenantID: string,
|
||||
now = new Date()
|
||||
) {
|
||||
await Promise.all([
|
||||
recalculateSharedModerationQueueQueueCounts(mongo, redis, tenantID),
|
||||
recalculateSharedModerationQueueTotalCounts(mongo, redis, tenantID),
|
||||
recalculateSharedStatusCommentCounts(mongo, redis, tenantID),
|
||||
recalculateSharedActionCommentCounts(mongo, redis, tenantID),
|
||||
recalculateSharedModerationQueueQueueCounts(mongo, redis, tenantID, now),
|
||||
recalculateSharedModerationQueueTotalCounts(mongo, redis, tenantID, now),
|
||||
recalculateSharedStatusCommentCounts(mongo, redis, tenantID, now),
|
||||
recalculateSharedActionCommentCounts(mongo, redis, tenantID, now),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -385,7 +386,8 @@ function fillAndConvertStringToNumber<
|
||||
export async function retrieveSharedActionCommentCounts(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
tenantID: string
|
||||
tenantID: string,
|
||||
now = new Date()
|
||||
): Promise<EncodedCommentActionCounts> {
|
||||
const key = commentCountsActionKey(tenantID);
|
||||
const freshKey = freshenKey(key);
|
||||
@@ -400,7 +402,7 @@ export async function retrieveSharedActionCommentCounts(
|
||||
.get(freshKey)
|
||||
.exec();
|
||||
if (!fresh || !actions) {
|
||||
return recalculateSharedActionCommentCounts(mongo, redis, tenantID);
|
||||
return recalculateSharedActionCommentCounts(mongo, redis, tenantID, now);
|
||||
}
|
||||
|
||||
return fillAndConvertStringToNumber(
|
||||
@@ -421,7 +423,8 @@ export async function retrieveSharedActionCommentCounts(
|
||||
export async function retrieveSharedStatusCommentCounts(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
tenantID: string
|
||||
tenantID: string,
|
||||
now = new Date()
|
||||
): Promise<CommentStatusCounts> {
|
||||
const key = commentCountsStatusKey(tenantID);
|
||||
const freshKey = freshenKey(key);
|
||||
@@ -436,7 +439,7 @@ export async function retrieveSharedStatusCommentCounts(
|
||||
.get(freshKey)
|
||||
.exec();
|
||||
if (!fresh || !statuses) {
|
||||
return recalculateSharedStatusCommentCounts(mongo, redis, tenantID);
|
||||
return recalculateSharedStatusCommentCounts(mongo, redis, tenantID, now);
|
||||
}
|
||||
|
||||
return fillAndConvertStringToNumber(
|
||||
@@ -457,7 +460,8 @@ export async function retrieveSharedStatusCommentCounts(
|
||||
export async function retrieveSharedModerationQueueTotal(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
tenantID: string
|
||||
tenantID: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const key = commentCountsModerationQueueTotalKey(tenantID);
|
||||
const freshKey = freshenKey(key);
|
||||
@@ -468,7 +472,12 @@ export async function retrieveSharedModerationQueueTotal(
|
||||
freshKey
|
||||
);
|
||||
if (fresh === null || total === null) {
|
||||
return recalculateSharedModerationQueueTotalCounts(mongo, redis, tenantID);
|
||||
return recalculateSharedModerationQueueTotalCounts(
|
||||
mongo,
|
||||
redis,
|
||||
tenantID,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
return parseInt(total, 10) || 0;
|
||||
@@ -486,7 +495,8 @@ export async function retrieveSharedModerationQueueTotal(
|
||||
export async function retrieveSharedModerationQueueQueuesCounts(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
tenantID: string
|
||||
tenantID: string,
|
||||
now = new Date()
|
||||
): Promise<CommentModerationCountsPerQueue> {
|
||||
const key = commentCountsModerationQueueQueuesKey(tenantID);
|
||||
const freshKey = freshenKey(key);
|
||||
@@ -505,7 +515,12 @@ export async function retrieveSharedModerationQueueQueuesCounts(
|
||||
.exec();
|
||||
if (!fresh || !queues) {
|
||||
logger.debug({ tenantID }, "comment moderation counts were not cached");
|
||||
return recalculateSharedModerationQueueQueueCounts(mongo, redis, tenantID);
|
||||
return recalculateSharedModerationQueueQueueCounts(
|
||||
mongo,
|
||||
redis,
|
||||
tenantID,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug({ tenantID }, "comment moderation counts were cached");
|
||||
|
||||
@@ -121,10 +121,9 @@ export interface UpsertStoryInput {
|
||||
export async function upsertStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id, url }: UpsertStoryInput
|
||||
{ id, url }: UpsertStoryInput,
|
||||
now = new Date()
|
||||
) {
|
||||
const now = new Date();
|
||||
|
||||
// Create the story, optionally sourcing the id from the input, additionally
|
||||
// porting in the tenantID.
|
||||
const update: { $setOnInsert: Story } = {
|
||||
@@ -171,15 +170,21 @@ export interface FindOrCreateStoryInput {
|
||||
export async function findOrCreateStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id, url }: FindOrCreateStoryInput
|
||||
{ id, url }: FindOrCreateStoryInput,
|
||||
now = new Date()
|
||||
) {
|
||||
if (id) {
|
||||
if (url) {
|
||||
// The URL was specified, this is an upsert operation.
|
||||
return upsertStory(mongo, tenantID, {
|
||||
id,
|
||||
url,
|
||||
});
|
||||
return upsertStory(
|
||||
mongo,
|
||||
tenantID,
|
||||
{
|
||||
id,
|
||||
url,
|
||||
},
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
// The URL was not specified, this is a lookup operation.
|
||||
@@ -192,7 +197,7 @@ export async function findOrCreateStory(
|
||||
throw new Error("cannot upsert an story without the url");
|
||||
}
|
||||
|
||||
return upsertStory(mongo, tenantID, { url });
|
||||
return upsertStory(mongo, tenantID, { url }, now);
|
||||
}
|
||||
|
||||
export type CreateStoryInput = Partial<Pick<Story, "metadata" | "scrapedAt">>;
|
||||
@@ -202,10 +207,9 @@ export async function createStory(
|
||||
tenantID: string,
|
||||
id: string,
|
||||
url: string,
|
||||
input: CreateStoryInput
|
||||
input: CreateStoryInput,
|
||||
now = new Date()
|
||||
) {
|
||||
const now = new Date();
|
||||
|
||||
// Create the story.
|
||||
const story: Story = {
|
||||
...input,
|
||||
@@ -289,14 +293,15 @@ export async function updateStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
input: UpdateStoryInput
|
||||
input: UpdateStoryInput,
|
||||
now = new Date()
|
||||
) {
|
||||
// Only update fields that have been updated.
|
||||
const update = {
|
||||
$set: {
|
||||
...dotize(input, { embedArrays: true }),
|
||||
// Always update the updated at time.
|
||||
updatedAt: new Date(),
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -326,14 +331,15 @@ export async function updateStorySettings(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
input: UpdateStorySettingsInput
|
||||
input: UpdateStorySettingsInput,
|
||||
now = new Date()
|
||||
) {
|
||||
// Only update fields that have been updated.
|
||||
const update = {
|
||||
$set: {
|
||||
...omitBy(dotize({ settings: input }, { embedArrays: true }), isNull),
|
||||
// Always update the updated at time.
|
||||
updatedAt: new Date(),
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -348,14 +354,19 @@ export async function updateStorySettings(
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function openStory(mongo: Db, tenantID: string, id: string) {
|
||||
export async function openStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
{
|
||||
$set: {
|
||||
closedAt: false,
|
||||
// Always update the updated at time.
|
||||
updatedAt: new Date(),
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
// False to return the updated document instead of the original
|
||||
@@ -366,14 +377,19 @@ export async function openStory(mongo: Db, tenantID: string, id: string) {
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function closeStory(mongo: Db, tenantID: string, id: string) {
|
||||
export async function closeStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
{
|
||||
$set: {
|
||||
closedAt: new Date(),
|
||||
closedAt: now,
|
||||
// Always update the updated at time.
|
||||
updatedAt: new Date(),
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
// False to return the updated document instead of the original
|
||||
|
||||
@@ -69,7 +69,11 @@ export type CreateTenantInput = Pick<
|
||||
* @param mongo the MongoDB connection used to create the tenant.
|
||||
* @param input the customizable parts of the Tenant available during creation
|
||||
*/
|
||||
export async function createTenant(mongo: Db, input: CreateTenantInput) {
|
||||
export async function createTenant(
|
||||
mongo: Db,
|
||||
input: CreateTenantInput,
|
||||
now = new Date()
|
||||
) {
|
||||
const defaults: Sub<Tenant, CreateTenantInput> = {
|
||||
// Create a new ID.
|
||||
id: uuid.v4(),
|
||||
@@ -119,7 +123,7 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
|
||||
stream: true,
|
||||
},
|
||||
key: generateSSOKey(),
|
||||
keyGeneratedAt: new Date(),
|
||||
keyGeneratedAt: now,
|
||||
},
|
||||
oidc: {
|
||||
enabled: false,
|
||||
@@ -175,6 +179,7 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
|
||||
labelActive: "Respected",
|
||||
icon: "thumb_up",
|
||||
},
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Create the new Tenant by merging it together with the defaults.
|
||||
|
||||
+536
-16
@@ -2,17 +2,26 @@ import bcrypt from "bcryptjs";
|
||||
import { Db, MongoError } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { Omit, Sub } from "talk-common/types";
|
||||
import { DeepPartial, Omit, Sub } from "talk-common/types";
|
||||
import { dotize } from "talk-common/utils/dotize";
|
||||
import {
|
||||
DuplicateEmailError,
|
||||
DuplicateUserError,
|
||||
LocalProfileAlreadySetError,
|
||||
LocalProfileNotSetError,
|
||||
TokenNotFoundError,
|
||||
UserAlreadyBannedError,
|
||||
UserAlreadySuspendedError,
|
||||
UsernameAlreadySetError,
|
||||
UserNotFoundError,
|
||||
} from "talk-server/errors";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
GQLBanStatus,
|
||||
GQLSuspensionStatus,
|
||||
GQLTimeRange,
|
||||
GQLUSER_ROLE,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "talk-server/logger";
|
||||
import {
|
||||
createConnectionOrderVariants,
|
||||
createIndexFactory,
|
||||
@@ -57,6 +66,10 @@ export interface GoogleProfile {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile is all the different profiles that a given User may have associated
|
||||
* with their account.
|
||||
*/
|
||||
export type Profile =
|
||||
| LocalProfile
|
||||
| OIDCProfile
|
||||
@@ -70,15 +83,168 @@ export interface Token {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* SuspensionStatusHistory SuspensionStatusHistory is the list of all suspension
|
||||
* events against a specific User.
|
||||
*/
|
||||
export interface SuspensionStatusHistory {
|
||||
/**
|
||||
* id is a specific reference for a particular suspension status that will be
|
||||
* used internally to update suspension records.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* from represents a range of time where a user suspension applies.
|
||||
*/
|
||||
from: GQLTimeRange;
|
||||
|
||||
/**
|
||||
* createdBy is the ID for the User that suspended the User. If `null`, the
|
||||
* suspension was created by the system.
|
||||
*/
|
||||
createdBy?: string;
|
||||
|
||||
/**
|
||||
* createdAt is the time that the given suspension time frame was created.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* modifiedBy is the ID for the User that modified the suspension for this
|
||||
* User. If `null`, the suspension has not been edited, or has been edited by
|
||||
* the system.
|
||||
*/
|
||||
modifiedBy?: string;
|
||||
|
||||
/**
|
||||
* modifiedAt is the time that the date that the given suspension time frame
|
||||
* was edited at.
|
||||
*/
|
||||
modifiedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* SuspensionStatus stores the user suspension status as well as the history of
|
||||
* changes.
|
||||
*/
|
||||
export interface SuspensionStatus {
|
||||
/**
|
||||
* history is the list of all suspension events against a specific User.
|
||||
*/
|
||||
history: SuspensionStatusHistory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* BanStatusHistory is the list of all ban events against a specific User.
|
||||
*/
|
||||
export interface BanStatusHistory {
|
||||
/**
|
||||
* id is a specific reference for a particular banned status that will be
|
||||
* used internally to update banned records.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* active, when true, indicates that the user is banned from this status.
|
||||
*/
|
||||
active: boolean;
|
||||
|
||||
/**
|
||||
* createdBy is the ID for the User that banned the User. If `null`, the ban
|
||||
* was created by the system.
|
||||
*/
|
||||
createdBy?: string;
|
||||
|
||||
/**
|
||||
* createdAt is the time that the given ban was added.
|
||||
*/
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* BanStatus contains information about a ban for a given User.
|
||||
*/
|
||||
export interface BanStatus {
|
||||
/**
|
||||
* active when true, indicates that the given user is banned.
|
||||
*/
|
||||
active: boolean;
|
||||
|
||||
/**
|
||||
* history is the list of all ban events against a specific User.
|
||||
*/
|
||||
history: BanStatusHistory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* UserStatus stores the user status information regarding moderation state.
|
||||
*/
|
||||
export interface UserStatus {
|
||||
/**
|
||||
* suspension stores the user suspension status as well as the history of
|
||||
* changes.
|
||||
*/
|
||||
suspension: SuspensionStatus;
|
||||
|
||||
/**
|
||||
* ban stores the user ban status as well as the history of changes.
|
||||
*/
|
||||
ban: BanStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* User is someone that leaves Comments, and logs in.
|
||||
*/
|
||||
export interface User extends TenantResource {
|
||||
/**
|
||||
* id is the identifier of the User.
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* username is the name of the User visible to other Users.
|
||||
*/
|
||||
username?: string;
|
||||
|
||||
/**
|
||||
* avatar is the url to the avatar for a specific User.
|
||||
*/
|
||||
avatar?: string;
|
||||
|
||||
/**
|
||||
* email is the current email address for the User.
|
||||
*/
|
||||
email?: string;
|
||||
|
||||
/**
|
||||
* emailVerified when true indicates that the given email address has been verified.
|
||||
*/
|
||||
emailVerified?: boolean;
|
||||
|
||||
/**
|
||||
* profiles is the array of profiles assigned to the user.
|
||||
*/
|
||||
profiles: Profile[];
|
||||
|
||||
/**
|
||||
* tokens lists the access tokens associated with the account.
|
||||
*/
|
||||
tokens: Token[];
|
||||
|
||||
/**
|
||||
* role is the current role of the User.
|
||||
*/
|
||||
role: GQLUSER_ROLE;
|
||||
|
||||
/**
|
||||
* status stores the user status information regarding moderation state.
|
||||
*/
|
||||
status: UserStatus;
|
||||
|
||||
/**
|
||||
* createdAt is the time that the User was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -138,6 +304,19 @@ export async function createUserIndexes(mongo: Db) {
|
||||
tenantID: 1,
|
||||
role: 1,
|
||||
});
|
||||
|
||||
// Suspension based User Connection pagination.
|
||||
await variants(createIndex, {
|
||||
tenantID: 1,
|
||||
"status.suspension.history.from.start": 1,
|
||||
"status.suspension.history.from.finish": 1,
|
||||
});
|
||||
|
||||
// Ban based User Connection pagination.
|
||||
await variants(createIndex, {
|
||||
tenantID: 1,
|
||||
"status.ban.active": 1,
|
||||
});
|
||||
}
|
||||
|
||||
function hashPassword(password: string): Promise<string> {
|
||||
@@ -146,16 +325,15 @@ function hashPassword(password: string): Promise<string> {
|
||||
|
||||
export type InsertUserInput = Omit<
|
||||
User,
|
||||
"id" | "tenantID" | "tokens" | "createdAt"
|
||||
"id" | "tenantID" | "tokens" | "status" | "createdAt"
|
||||
>;
|
||||
|
||||
export async function insertUser(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
input: InsertUserInput
|
||||
input: InsertUserInput,
|
||||
now = new Date()
|
||||
) {
|
||||
const now = new Date();
|
||||
|
||||
// Create a new ID for the user.
|
||||
const id = uuid.v4();
|
||||
|
||||
@@ -165,6 +343,10 @@ export async function insertUser(
|
||||
id,
|
||||
tenantID,
|
||||
tokens: [],
|
||||
status: {
|
||||
suspension: { history: [] },
|
||||
ban: { active: false, history: [] },
|
||||
},
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
@@ -332,7 +514,7 @@ export async function updateUserPassword(
|
||||
throw new LocalProfileNotSetError();
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occured");
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value || null;
|
||||
@@ -384,7 +566,7 @@ export async function setUserUsername(
|
||||
throw new UsernameAlreadySetError();
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occured");
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -430,7 +612,7 @@ export async function updateUserUsername(
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occured");
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -492,7 +674,7 @@ export async function setUserEmail(
|
||||
throw new UsernameAlreadySetError();
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occured");
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -548,7 +730,7 @@ export async function updateUserEmail(
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occured");
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -595,7 +777,7 @@ export async function updateUserAvatar(
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occured");
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -672,7 +854,7 @@ export async function setUserLocalProfile(
|
||||
throw new LocalProfileAlreadySetError();
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occured");
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -682,13 +864,14 @@ export async function createUserToken(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
userID: string,
|
||||
name: string
|
||||
name: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the Token that we'll be adding to the User.
|
||||
const token: Readonly<Token> = {
|
||||
id: uuid.v4(),
|
||||
name,
|
||||
createdAt: new Date(),
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
@@ -748,7 +931,7 @@ export async function deactivateUserToken(
|
||||
throw new TokenNotFoundError();
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occured");
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
// We have to typecast here because we know at this point that the record does
|
||||
@@ -798,3 +981,340 @@ async function retrieveConnection(
|
||||
// Return a connection.
|
||||
return resolveConnection(query, input, user => user.createdAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* banUser will ban a specific user from interacting with the site.
|
||||
*
|
||||
* @param mongo the mongo database handle
|
||||
* @param tenantID the Tenant's ID where the User exists
|
||||
* @param id the ID of the user being banned
|
||||
* @param createdBy the ID of the user banning the above mentioned user
|
||||
* @param now the current date
|
||||
*/
|
||||
export async function banUser(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
createdBy: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the new ban.
|
||||
const banHistory: BanStatusHistory = {
|
||||
id: uuid(),
|
||||
active: true,
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Try to update the user if the user isn't already banned.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenantID,
|
||||
"status.ban.active": {
|
||||
$ne: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"status.ban.active": true,
|
||||
},
|
||||
$push: {
|
||||
"status.ban.history": banHistory,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// Get the user so we can figure out why the ban operation failed.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// Check to see if the user is already banned.
|
||||
const ban = consolidateUserBanStatus(user.status.ban);
|
||||
if (ban.active) {
|
||||
throw new UserAlreadyBannedError();
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* removeUserBan will lift a user ban from a User allowing them to interact with
|
||||
* the site again.
|
||||
*
|
||||
* @param mongo the mongo database handle
|
||||
* @param tenantID the Tenant's ID where the User exists
|
||||
* @param id the ID of the user having their ban lifted
|
||||
* @param createdBy the ID of the user lifting the ban
|
||||
* @param now the current date
|
||||
*/
|
||||
export async function removeUserBan(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
createdBy: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the new ban.
|
||||
const ban: BanStatusHistory = {
|
||||
id: uuid(),
|
||||
active: false,
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Try to update the user if the user isn't already banned.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenantID,
|
||||
$or: [
|
||||
{
|
||||
"status.ban.active": {
|
||||
$ne: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
"status.ban.history": {
|
||||
$size: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"status.ban.active": false,
|
||||
},
|
||||
$push: {
|
||||
"status.ban.history": ban,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// Get the user so we can figure out why the ban operation failed.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// The user wasn't banned already, so nothing needs to be done!
|
||||
return user;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* suspendUser will suspend a user for a specific time range from interacting
|
||||
* with the site.
|
||||
*
|
||||
* @param mongo the mongo database handle
|
||||
* @param tenantID the Tenant's ID where the User exists
|
||||
* @param id the ID of the user being suspended
|
||||
* @param createdBy the ID of the user banning the above mentioned user
|
||||
* @param from the range of time that the user is being banned for
|
||||
* @param now the current date
|
||||
*/
|
||||
export async function suspendUser(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
createdBy: string,
|
||||
finish: Date,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the new suspension.
|
||||
const suspension: SuspensionStatusHistory = {
|
||||
id: uuid(),
|
||||
from: {
|
||||
start: now,
|
||||
finish,
|
||||
},
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Try to update the user if the user isn't already suspended.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenantID,
|
||||
"status.suspension.history": {
|
||||
$not: {
|
||||
$elemMatch: {
|
||||
"from.start": {
|
||||
$lte: now,
|
||||
},
|
||||
"from.finish": {
|
||||
$gt: now,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$push: {
|
||||
"status.suspension.history": suspension,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// Get the user so we can figure out why the suspend operation failed.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// Check to see if the user is already suspended.
|
||||
const suspended = consolidateUserSuspensionStatus(
|
||||
user.status.suspension,
|
||||
now
|
||||
);
|
||||
if (suspended.active && suspended.until) {
|
||||
throw new UserAlreadySuspendedError(suspended.until);
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* removeUserSuspensions will lift any active suspensions.
|
||||
*
|
||||
* @param mongo the mongo database handle
|
||||
* @param tenantID the Tenant's ID where the User exists
|
||||
* @param id the ID of the User having their suspension lifted
|
||||
* @param modifiedBy the ID of the User lifting the suspension
|
||||
* @param now the current date
|
||||
*/
|
||||
export async function removeActiveUserSuspensions(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
modifiedBy: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Prepare the update payload.
|
||||
const update: DeepPartial<SuspensionStatusHistory> = {
|
||||
from: {
|
||||
finish: now,
|
||||
},
|
||||
modifiedAt: now,
|
||||
modifiedBy,
|
||||
};
|
||||
|
||||
// Try to update the user suspension times.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ tenantID, id },
|
||||
{
|
||||
$set: dotize({
|
||||
"status.suspension.history.$[active]": update,
|
||||
}),
|
||||
},
|
||||
{
|
||||
arrayFilters: [
|
||||
// Change the finish date on all suspension records that indicate their
|
||||
// active time within our current range.
|
||||
{
|
||||
"active.from.start": { $lte: now },
|
||||
"active.from.finish": { $gt: now },
|
||||
},
|
||||
],
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// Get the user so we can figure out why the suspend operation failed.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// The user wasn't already suspended, so nothing needs to be done!
|
||||
return user;
|
||||
}
|
||||
|
||||
logger.debug({ result }, "finished update operation");
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export type ConsolidatedBanStatus = Omit<GQLBanStatus, "history"> &
|
||||
Pick<BanStatus, "history">;
|
||||
|
||||
export function consolidateUserBanStatus(
|
||||
ban: User["status"]["ban"]
|
||||
): ConsolidatedBanStatus {
|
||||
return ban;
|
||||
}
|
||||
|
||||
export type ConsolidatedSuspensionStatus = Omit<
|
||||
GQLSuspensionStatus,
|
||||
"history"
|
||||
> &
|
||||
Pick<SuspensionStatus, "history">;
|
||||
|
||||
export function consolidateUserSuspensionStatus(
|
||||
suspension: User["status"]["suspension"],
|
||||
now = new Date()
|
||||
): ConsolidatedSuspensionStatus {
|
||||
return suspension.history.reduce(
|
||||
(status: ConsolidatedSuspensionStatus, history) => {
|
||||
// Check to see if we're currently suspended.
|
||||
if (history.from.start <= now && history.from.finish > now) {
|
||||
status.active = true;
|
||||
|
||||
// Ensure that we have the furthest suspension finish time.
|
||||
if (!status.until || status.until < history.from.finish) {
|
||||
status.until = history.from.finish;
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
},
|
||||
{
|
||||
active: false,
|
||||
history: suspension.history,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConsolidatedUserStatus {
|
||||
suspension: ConsolidatedSuspensionStatus;
|
||||
ban: ConsolidatedBanStatus;
|
||||
}
|
||||
|
||||
export function consolidateUserStatus(
|
||||
status: User["status"],
|
||||
now = new Date()
|
||||
): ConsolidatedUserStatus {
|
||||
// Return the status.
|
||||
return {
|
||||
suspension: consolidateUserSuspensionStatus(status.suspension, now),
|
||||
ban: consolidateUserBanStatus(status.ban),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user